patchwork-os 0.2.0-alpha.35 → 0.2.0-alpha.37
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 +70 -15
- 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 +46 -0
- 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 +194 -5
- package/dist/commands/recipe.js.map +1 -1
- package/dist/commands/recipeInstall.js +93 -4
- 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 +1713 -0
- package/dist/connectorRoutes.js.map +1 -0
- package/dist/connectors/asana.js +6 -7
- package/dist/connectors/asana.js.map +1 -1
- package/dist/connectors/baseConnector.d.ts +20 -0
- package/dist/connectors/baseConnector.js +45 -4
- package/dist/connectors/baseConnector.js.map +1 -1
- package/dist/connectors/discord.js +6 -7
- package/dist/connectors/discord.js.map +1 -1
- package/dist/connectors/gmail.js +39 -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.js +22 -6
- package/dist/connectors/googleDrive.js.map +1 -1
- package/dist/connectors/linear.js +2 -2
- 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/slack.d.ts +15 -0
- package/dist/connectors/slack.js +54 -4
- 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/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 +479 -17
- 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 +1 -0
- package/dist/recipeOrchestration.js +173 -13
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipeRoutes.d.ts +154 -0
- package/dist/recipeRoutes.js +1107 -0
- package/dist/recipeRoutes.js.map +1 -0
- package/dist/recipes/chainedRunner.d.ts +15 -0
- package/dist/recipes/chainedRunner.js +73 -8
- 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/installer.js +3 -3
- package/dist/recipes/installer.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/resolveRecipePath.d.ts +69 -0
- package/dist/recipes/resolveRecipePath.js +202 -0
- package/dist/recipes/resolveRecipePath.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/index.d.ts +2 -0
- package/dist/recipes/tools/index.js +2 -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.js +6 -3
- package/dist/recipes/tools/linear.js.map +1 -1
- 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 +7 -3
- 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 +7 -0
- package/dist/recipes/yamlRunner.js +107 -13
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +44 -1
- package/dist/recipesHttp.js +168 -15
- package/dist/recipesHttp.js.map +1 -1
- package/dist/runLog.d.ts +14 -0
- package/dist/runLog.js +88 -4
- 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 +71 -10
- package/dist/server.js +363 -1703
- 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 +8 -0
- package/dist/streamableHttp.js +112 -21
- 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/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 +4 -2
- 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/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
|
@@ -0,0 +1,1107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe + run-audit route dispatcher — extracted from src/server.ts.
|
|
3
|
+
*
|
|
4
|
+
* Owns 16 routes covering the recipe authoring loop (CRUD + lint + run),
|
|
5
|
+
* the run-audit log (`/runs`, `/runs/:seq`, replay, plan), the public
|
|
6
|
+
* template registry (`/templates`), and recipe installation
|
|
7
|
+
* (`/recipes/install`). Plus the `/activation-metrics` siblings that
|
|
8
|
+
* read the same audit log.
|
|
9
|
+
*
|
|
10
|
+
* DI shape: handlers depend on 12 nullable callbacks the bridge wires
|
|
11
|
+
* onto the Server instance post-construction (`runRecipeFn`,
|
|
12
|
+
* `recipesFn`, etc.). They're passed as a `RecipeRouteDeps` struct
|
|
13
|
+
* matching the pattern from oauthRoutes.ts and mcpRoutes.ts.
|
|
14
|
+
*
|
|
15
|
+
* Module-level state: the `/templates` 5-minute cache used to live as
|
|
16
|
+
* `_templatesCache`/`_templatesCacheTs` instance fields on Server.
|
|
17
|
+
* Lifetime is process-wide either way (Server is a singleton in
|
|
18
|
+
* practice), so hoisting to module scope here is equivalent and avoids
|
|
19
|
+
* threading a mutable holder through `deps`.
|
|
20
|
+
*
|
|
21
|
+
* Mechanical lift: handler bodies are byte-identical save for
|
|
22
|
+
* `deps.<fn>` replacing `this.<fn>` and module-scoped cache vars
|
|
23
|
+
* replacing `this._templatesCache`. A few routes that previously used
|
|
24
|
+
* `await` directly in their async parent closure are wrapped in
|
|
25
|
+
* `void (async () => {...})()` so this module can return boolean
|
|
26
|
+
* synchronously — same micro-task tradeoff documented in
|
|
27
|
+
* connectorRoutes.ts.
|
|
28
|
+
*/
|
|
29
|
+
import os from "node:os";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { computeSummary as computeActivationSummary, loadMetrics as loadActivationMetrics, } from "./activationMetrics.js";
|
|
32
|
+
import { validateSafeUrl } from "./ssrfGuard.js";
|
|
33
|
+
// 5-minute cache of the public template registry from the patchworkos/recipes
|
|
34
|
+
// GitHub repo. Process-wide; hoisted out of Server class state.
|
|
35
|
+
let templatesCache = null;
|
|
36
|
+
let templatesCacheTs = 0;
|
|
37
|
+
// G-security R2 C-3 / I-3 / F-02: HTTP `vars` validation.
|
|
38
|
+
//
|
|
39
|
+
// The post-render path jail in `src/recipes/resolveRecipePath.ts` is the
|
|
40
|
+
// actual defense against template-driven traversal — but rejecting bad
|
|
41
|
+
// vars at the HTTP layer is cheaper and gives the caller a precise 400
|
|
42
|
+
// instead of a generic 500 from the runner. Validation rules:
|
|
43
|
+
//
|
|
44
|
+
// - keys — `/^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/` (identifier-ish, ≤64)
|
|
45
|
+
// - values — `/^[\w\-. :+@,]+$/u` (no `/`, no `..`, no
|
|
46
|
+
// `~`, no control chars)
|
|
47
|
+
// - type — strings only; numbers/objects/arrays → 400 (type-strict
|
|
48
|
+
// per R3 amendment 4 / I-3, prevents JSON.stringify smuggling
|
|
49
|
+
// a `..` segment into a coerced value at render time).
|
|
50
|
+
const VARS_KEY_RE = /^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/;
|
|
51
|
+
const VARS_VALUE_RE = /^[\w\-. :+@,]+$/u;
|
|
52
|
+
/** Validate the HTTP-supplied `vars` object. Returns null on success. */
|
|
53
|
+
export function validateRecipeVars(vars) {
|
|
54
|
+
if (vars == null)
|
|
55
|
+
return null;
|
|
56
|
+
if (typeof vars !== "object" || Array.isArray(vars)) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
error: "vars must be a plain object of string→string entries",
|
|
60
|
+
field: "type",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
64
|
+
if (!VARS_KEY_RE.test(key)) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
error: `vars key "${key}" must match /^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/`,
|
|
68
|
+
field: "key",
|
|
69
|
+
offendingKey: key,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (typeof value !== "string") {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error: `vars["${key}"] must be a string (got ${Array.isArray(value) ? "array" : typeof value})`,
|
|
76
|
+
field: "type",
|
|
77
|
+
offendingKey: key,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (value.length === 0 || value.length > 1024) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: `vars["${key}"] must be a non-empty string ≤ 1024 chars`,
|
|
84
|
+
field: "value",
|
|
85
|
+
offendingKey: key,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (!VARS_VALUE_RE.test(value)) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: `vars["${key}"] contains disallowed characters (no "/", "..", "~", or control chars)`,
|
|
92
|
+
field: "value",
|
|
93
|
+
offendingKey: key,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
// ---------------------------------------------------------------------
|
|
100
|
+
// BEGIN A-PR2 EDIT BLOCK — body-cap helpers (dogfood R2 M-1 / F-08)
|
|
101
|
+
//
|
|
102
|
+
// Per-route caps; install is the strictest because the request only carries
|
|
103
|
+
// a single `source` field. See PR description for sizing rationale.
|
|
104
|
+
// Coordination note: A-PR1 also touches `recipeRoutes.ts` for `vars`
|
|
105
|
+
// validation; the helper APIs here are exclusively A-PR2's.
|
|
106
|
+
// ---------------------------------------------------------------------
|
|
107
|
+
export const RECIPE_ROUTE_BODY_CAPS = {
|
|
108
|
+
/** /recipes/install — `{ source: string }` body. */
|
|
109
|
+
install: 4 * 1024,
|
|
110
|
+
/** /recipes/generate — NL prompt. */
|
|
111
|
+
generate: 4 * 1024,
|
|
112
|
+
/** /recipes/:name/run + /recipes/run — vars envelope. */
|
|
113
|
+
run: 32 * 1024,
|
|
114
|
+
/** /recipes (POST), PUT/PATCH /recipes/:name, /recipes/lint — yaml content. */
|
|
115
|
+
content: 256 * 1024,
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Read an HTTP request body up to `max` bytes, parse as JSON, return result.
|
|
119
|
+
*
|
|
120
|
+
* Returns one of three discriminated shapes:
|
|
121
|
+
* - `{ ok: true, value }` — body parsed successfully.
|
|
122
|
+
* - `{ ok: false, code: "too_large" }` — body exceeded `max`; request was
|
|
123
|
+
* destroyed eagerly and the response should be 413.
|
|
124
|
+
* - `{ ok: false, code: "invalid_json" }` — body was valid bytes but failed
|
|
125
|
+
* `JSON.parse`; response should be 400.
|
|
126
|
+
*
|
|
127
|
+
* Bytes are accumulated into a single Buffer so the helper can enforce the
|
|
128
|
+
* cap incrementally — a 1 GB upload is rejected after the first overflowing
|
|
129
|
+
* chunk rather than after the full body lands in memory.
|
|
130
|
+
*/
|
|
131
|
+
export function readJsonBody(req, max) {
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
const chunks = [];
|
|
134
|
+
let total = 0;
|
|
135
|
+
let aborted = false;
|
|
136
|
+
const onData = (chunk) => {
|
|
137
|
+
if (aborted)
|
|
138
|
+
return;
|
|
139
|
+
total += chunk.byteLength;
|
|
140
|
+
if (total > max) {
|
|
141
|
+
aborted = true;
|
|
142
|
+
// Resolve immediately so the route can write 413; do NOT destroy the
|
|
143
|
+
// socket here — destroying mid-upload races with the response write
|
|
144
|
+
// and the client sees EPIPE/ECONNRESET before reading the body.
|
|
145
|
+
// Subsequent chunks land in `onData` again but the `aborted` guard
|
|
146
|
+
// discards them, draining the upload until the client emits `end`.
|
|
147
|
+
resolve({ ok: false, code: "too_large" });
|
|
148
|
+
// Force the underlying stream to keep flowing so buffered upload
|
|
149
|
+
// data drains naturally. Without this Node may pause the stream
|
|
150
|
+
// when nothing is consuming chunks, leaving the socket half-open.
|
|
151
|
+
try {
|
|
152
|
+
req.resume();
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// best-effort
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
chunks.push(chunk);
|
|
160
|
+
};
|
|
161
|
+
const onEnd = () => {
|
|
162
|
+
if (aborted)
|
|
163
|
+
return;
|
|
164
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
165
|
+
if (raw.length === 0) {
|
|
166
|
+
// Empty bodies are passed through as `undefined`; callers decide
|
|
167
|
+
// whether that's an error (most parse `{...}` immediately).
|
|
168
|
+
resolve({ ok: true, value: undefined });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
resolve({ ok: true, value: JSON.parse(raw) });
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
resolve({ ok: false, code: "invalid_json" });
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
const onError = () => {
|
|
179
|
+
if (aborted)
|
|
180
|
+
return;
|
|
181
|
+
aborted = true;
|
|
182
|
+
resolve({ ok: false, code: "invalid_json" });
|
|
183
|
+
};
|
|
184
|
+
req.on("data", onData);
|
|
185
|
+
req.once("end", onEnd);
|
|
186
|
+
req.once("error", onError);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Standard 413 helper used by the routes when `readJsonBody` overflows.
|
|
191
|
+
*
|
|
192
|
+
* Note: we do NOT destroy the underlying socket — `res.end()` is sufficient.
|
|
193
|
+
* Destroying mid-upload is fragile across platforms (macOS races
|
|
194
|
+
* EPIPE/ECONNRESET to the client before the 413 body is delivered).
|
|
195
|
+
* The matching `readJsonBody` no-op-data drain keeps the upload flowing
|
|
196
|
+
* until the client emits `end`, so the server returns the response cleanly.
|
|
197
|
+
*/
|
|
198
|
+
function respond413(res, max) {
|
|
199
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
200
|
+
res.end(JSON.stringify({
|
|
201
|
+
ok: false,
|
|
202
|
+
error: `Request body exceeds ${max}-byte limit`,
|
|
203
|
+
code: "body_too_large",
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
/** Standard 400 helper for malformed JSON. */
|
|
207
|
+
function respondInvalidJson(res) {
|
|
208
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
209
|
+
res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Try to handle a recipe / run-audit / template route. Returns true if
|
|
213
|
+
* the route was dispatched (caller should `return` from the request
|
|
214
|
+
* handler), false if no route matched.
|
|
215
|
+
*
|
|
216
|
+
* Must be called AFTER bearer-auth — none of these routes are public.
|
|
217
|
+
*/
|
|
218
|
+
export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
|
|
219
|
+
const recipeNameRunMatch = req.method === "POST"
|
|
220
|
+
? /^\/recipes\/([^/]+)\/run$/.exec(parsedUrl.pathname)
|
|
221
|
+
: null;
|
|
222
|
+
if (recipeNameRunMatch) {
|
|
223
|
+
// A-PR2: bounded JSON read at RECIPE_ROUTE_BODY_CAPS.run (32 KB).
|
|
224
|
+
const nameFromPath = decodeURIComponent(recipeNameRunMatch[1] ?? "");
|
|
225
|
+
void (async () => {
|
|
226
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.run);
|
|
227
|
+
if (!parsedBody.ok) {
|
|
228
|
+
if (parsedBody.code === "too_large") {
|
|
229
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.run);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
respondInvalidJson(res);
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const parsed = parsedBody.value ?? {};
|
|
238
|
+
const varsRaw = parsed.vars ?? parsed.inputs;
|
|
239
|
+
const varsErr = validateRecipeVars(varsRaw);
|
|
240
|
+
if (varsErr) {
|
|
241
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
242
|
+
res.end(JSON.stringify(varsErr));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const vars = varsRaw && typeof varsRaw === "object" && !Array.isArray(varsRaw)
|
|
246
|
+
? varsRaw
|
|
247
|
+
: undefined;
|
|
248
|
+
if (!deps.runRecipeFn) {
|
|
249
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
250
|
+
res.end(JSON.stringify({
|
|
251
|
+
ok: false,
|
|
252
|
+
error: "Recipe execution unavailable — requires --claude-driver subprocess",
|
|
253
|
+
}));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const result = await deps.runRecipeFn(nameFromPath, vars);
|
|
257
|
+
res.writeHead(result.ok ? 200 : 400, {
|
|
258
|
+
"Content-Type": "application/json",
|
|
259
|
+
});
|
|
260
|
+
res.end(JSON.stringify(result));
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
respondInvalidJson(res);
|
|
264
|
+
}
|
|
265
|
+
})();
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
if (parsedUrl.pathname === "/recipes/run" && req.method === "POST") {
|
|
269
|
+
// A-PR2: bounded JSON read at RECIPE_ROUTE_BODY_CAPS.run (32 KB).
|
|
270
|
+
void (async () => {
|
|
271
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.run);
|
|
272
|
+
if (!parsedBody.ok) {
|
|
273
|
+
if (parsedBody.code === "too_large") {
|
|
274
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.run);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
respondInvalidJson(res);
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const parsed = parsedBody.value ?? {};
|
|
283
|
+
const name = parsed.name;
|
|
284
|
+
const varsErr = validateRecipeVars(parsed.vars);
|
|
285
|
+
if (varsErr) {
|
|
286
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
287
|
+
res.end(JSON.stringify(varsErr));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const vars = parsed.vars &&
|
|
291
|
+
typeof parsed.vars === "object" &&
|
|
292
|
+
!Array.isArray(parsed.vars)
|
|
293
|
+
? parsed.vars
|
|
294
|
+
: undefined;
|
|
295
|
+
if (typeof name !== "string" || !name) {
|
|
296
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
297
|
+
res.end(JSON.stringify({ ok: false, error: "name required" }));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (!deps.runRecipeFn) {
|
|
301
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
302
|
+
res.end(JSON.stringify({
|
|
303
|
+
ok: false,
|
|
304
|
+
error: "Recipe execution unavailable — requires --claude-driver subprocess",
|
|
305
|
+
}));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const result = await deps.runRecipeFn(name, vars);
|
|
309
|
+
res.writeHead(result.ok ? 200 : 400, {
|
|
310
|
+
"Content-Type": "application/json",
|
|
311
|
+
});
|
|
312
|
+
res.end(JSON.stringify(result));
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
respondInvalidJson(res);
|
|
316
|
+
}
|
|
317
|
+
})();
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
if (parsedUrl.pathname === "/activation-metrics" && req.method === "GET") {
|
|
321
|
+
try {
|
|
322
|
+
const metrics = loadActivationMetrics();
|
|
323
|
+
const summary = computeActivationSummary(metrics);
|
|
324
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
325
|
+
res.end(JSON.stringify({ metrics, summary }));
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
329
|
+
res.end(JSON.stringify({
|
|
330
|
+
error: err instanceof Error ? err.message : String(err),
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
if (parsedUrl.pathname === "/runs" && req.method === "GET") {
|
|
336
|
+
try {
|
|
337
|
+
const sp = parsedUrl.searchParams;
|
|
338
|
+
const limitRaw = sp.get("limit");
|
|
339
|
+
const afterRaw = sp.get("after");
|
|
340
|
+
const trigger = sp.get("trigger");
|
|
341
|
+
const status = sp.get("status");
|
|
342
|
+
const recipe = sp.get("recipe");
|
|
343
|
+
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
|
|
344
|
+
const after = afterRaw ? Number.parseInt(afterRaw, 10) : Number.NaN;
|
|
345
|
+
const runs = deps.runsFn?.({
|
|
346
|
+
...(Number.isFinite(limit) && { limit }),
|
|
347
|
+
...(trigger && { trigger }),
|
|
348
|
+
...(status && { status }),
|
|
349
|
+
...(recipe && { recipe }),
|
|
350
|
+
...(Number.isFinite(after) && { after }),
|
|
351
|
+
}) ?? [];
|
|
352
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
353
|
+
res.end(JSON.stringify({ runs }));
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
357
|
+
res.end(JSON.stringify({
|
|
358
|
+
error: err instanceof Error ? err.message : String(err),
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
// GET /runs/:seq — single run detail (includes stepResults if present)
|
|
364
|
+
const runDetailMatch = req.method === "GET" ? /^\/runs\/(\d+)$/.exec(parsedUrl.pathname) : null;
|
|
365
|
+
if (runDetailMatch?.[1]) {
|
|
366
|
+
const seq = Number.parseInt(runDetailMatch[1], 10);
|
|
367
|
+
try {
|
|
368
|
+
const run = deps.runDetailFn?.(seq) ?? null;
|
|
369
|
+
if (!run) {
|
|
370
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
371
|
+
res.end(JSON.stringify({ error: "not_found" }));
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
375
|
+
res.end(JSON.stringify({ run }));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
380
|
+
res.end(JSON.stringify({
|
|
381
|
+
error: err instanceof Error ? err.message : String(err),
|
|
382
|
+
}));
|
|
383
|
+
}
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
// POST /runs/:seq/replay — VD-4 mocked replay. Re-runs the recipe with all
|
|
387
|
+
// tool/agent execution intercepted to return captured outputs from the
|
|
388
|
+
// original run. No external IO, no side effects. Real-mode replay is not
|
|
389
|
+
// exposed here yet — must ship separately with confirmation UX +
|
|
390
|
+
// kill-switch interaction.
|
|
391
|
+
const runReplayMatch = req.method === "POST"
|
|
392
|
+
? /^\/runs\/(\d+)\/replay$/.exec(parsedUrl.pathname)
|
|
393
|
+
: null;
|
|
394
|
+
if (runReplayMatch?.[1]) {
|
|
395
|
+
const seq = Number.parseInt(runReplayMatch[1], 10);
|
|
396
|
+
void (async () => {
|
|
397
|
+
try {
|
|
398
|
+
if (!deps.runReplayFn) {
|
|
399
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
400
|
+
res.end(JSON.stringify({ error: "replay_unavailable" }));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const result = await deps.runReplayFn(seq);
|
|
404
|
+
if (result.error === "run_not_found") {
|
|
405
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
406
|
+
}
|
|
407
|
+
else if (!result.ok) {
|
|
408
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
412
|
+
}
|
|
413
|
+
res.end(JSON.stringify(result));
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
417
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
418
|
+
res.end(JSON.stringify({ error: msg }));
|
|
419
|
+
}
|
|
420
|
+
})();
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
// GET /runs/:seq/plan — dry-run plan for the recipe that produced this run
|
|
424
|
+
const runPlanMatch = req.method === "GET"
|
|
425
|
+
? /^\/runs\/(\d+)\/plan$/.exec(parsedUrl.pathname)
|
|
426
|
+
: null;
|
|
427
|
+
if (runPlanMatch?.[1]) {
|
|
428
|
+
const seq = Number.parseInt(runPlanMatch[1], 10);
|
|
429
|
+
void (async () => {
|
|
430
|
+
try {
|
|
431
|
+
const run = deps.runDetailFn?.(seq) ?? null;
|
|
432
|
+
if (!run) {
|
|
433
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
434
|
+
res.end(JSON.stringify({ error: "run_not_found" }));
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (!deps.runPlanFn) {
|
|
438
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
439
|
+
res.end(JSON.stringify({ error: "plan_unavailable" }));
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
// triggerSource appends ":agent" suffix — strip before file lookup
|
|
443
|
+
const recipeName = run.recipeName.replace(/:agent$/, "");
|
|
444
|
+
const plan = await deps.runPlanFn(recipeName);
|
|
445
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
446
|
+
res.end(JSON.stringify({ plan }));
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
450
|
+
const status = msg.includes("not found") || msg.includes("ENOENT") ? 404 : 500;
|
|
451
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
452
|
+
res.end(JSON.stringify({ error: msg }));
|
|
453
|
+
}
|
|
454
|
+
})();
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
if (parsedUrl.pathname === "/recipes/generate" && req.method === "POST") {
|
|
458
|
+
void (async () => {
|
|
459
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.generate);
|
|
460
|
+
if (!parsedBody.ok) {
|
|
461
|
+
if (parsedBody.code === "too_large") {
|
|
462
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.generate);
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
respondInvalidJson(res);
|
|
466
|
+
}
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const prompt = parsedBody.value
|
|
470
|
+
?.prompt;
|
|
471
|
+
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
472
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
473
|
+
res.end(JSON.stringify({
|
|
474
|
+
ok: false,
|
|
475
|
+
error: "prompt must be a non-empty string",
|
|
476
|
+
}));
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (!deps.generateRecipeFn) {
|
|
480
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
481
|
+
res.end(JSON.stringify({
|
|
482
|
+
ok: false,
|
|
483
|
+
error: "Recipe generation unavailable — requires --claude-driver subprocess",
|
|
484
|
+
unavailable: true,
|
|
485
|
+
}));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
const result = await deps.generateRecipeFn(prompt.trim());
|
|
490
|
+
const status = result.ok ? 200 : result.unavailable ? 503 : 422;
|
|
491
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
492
|
+
res.end(JSON.stringify(result));
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
496
|
+
res.end(JSON.stringify({
|
|
497
|
+
ok: false,
|
|
498
|
+
error: err instanceof Error ? err.message : String(err),
|
|
499
|
+
}));
|
|
500
|
+
}
|
|
501
|
+
})();
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
if (req.url === "/recipes" && req.method === "POST") {
|
|
505
|
+
// A-PR2: bounded JSON read at RECIPE_ROUTE_BODY_CAPS.content (256 KB).
|
|
506
|
+
void (async () => {
|
|
507
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.content);
|
|
508
|
+
if (!parsedBody.ok) {
|
|
509
|
+
if (parsedBody.code === "too_large") {
|
|
510
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.content);
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
respondInvalidJson(res);
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
const draft = (parsedBody.value ?? {});
|
|
519
|
+
if (typeof draft.name !== "string" || !draft.name) {
|
|
520
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
521
|
+
res.end(JSON.stringify({ ok: false, error: "name required" }));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (!deps.saveRecipeFn) {
|
|
525
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
526
|
+
res.end(JSON.stringify({
|
|
527
|
+
ok: false,
|
|
528
|
+
error: "Recipe saving unavailable",
|
|
529
|
+
}));
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const result = deps.saveRecipeFn(draft);
|
|
533
|
+
res.writeHead(result.ok ? 201 : 400, {
|
|
534
|
+
"Content-Type": "application/json",
|
|
535
|
+
});
|
|
536
|
+
res.end(JSON.stringify(result));
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
respondInvalidJson(res);
|
|
540
|
+
}
|
|
541
|
+
})();
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
// PATCH /recipes/:name/trust — update trust level for a recipe.
|
|
545
|
+
const recipeTrustMatch = req.method === "PATCH"
|
|
546
|
+
? /^\/recipes\/([^/]+)\/trust$/.exec(parsedUrl.pathname)
|
|
547
|
+
: null;
|
|
548
|
+
if (recipeTrustMatch?.[1]) {
|
|
549
|
+
const name = decodeURIComponent(recipeTrustMatch[1]);
|
|
550
|
+
void (async () => {
|
|
551
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.install);
|
|
552
|
+
if (!parsedBody.ok) {
|
|
553
|
+
if (parsedBody.code === "too_large") {
|
|
554
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.install);
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
respondInvalidJson(res);
|
|
558
|
+
}
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const level = parsedBody.value
|
|
562
|
+
?.level;
|
|
563
|
+
if (typeof level !== "string" || !level) {
|
|
564
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
565
|
+
res.end(JSON.stringify({ ok: false, error: "level (string) required" }));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (!deps.setRecipeTrustFn) {
|
|
569
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
570
|
+
res.end(JSON.stringify({ ok: false, error: "Trust management unavailable" }));
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const result = deps.setRecipeTrustFn(name, level);
|
|
574
|
+
res.writeHead(result.ok ? 200 : 400, {
|
|
575
|
+
"Content-Type": "application/json",
|
|
576
|
+
});
|
|
577
|
+
res.end(JSON.stringify(result));
|
|
578
|
+
})();
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
const recipePatchMatch = /^\/recipes\/([^/]+)$/.exec(parsedUrl.pathname);
|
|
582
|
+
if (recipePatchMatch && req.method === "PATCH") {
|
|
583
|
+
// A-PR2: bounded JSON read at RECIPE_ROUTE_BODY_CAPS.content (256 KB).
|
|
584
|
+
const name = decodeURIComponent(recipePatchMatch[1] ?? "");
|
|
585
|
+
void (async () => {
|
|
586
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.content);
|
|
587
|
+
if (!parsedBody.ok) {
|
|
588
|
+
if (parsedBody.code === "too_large") {
|
|
589
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.content);
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
respondInvalidJson(res);
|
|
593
|
+
}
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
const body = parsedBody.value ?? {};
|
|
598
|
+
if (typeof body.enabled !== "boolean") {
|
|
599
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
600
|
+
res.end(JSON.stringify({
|
|
601
|
+
ok: false,
|
|
602
|
+
error: "enabled (boolean) required",
|
|
603
|
+
}));
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (!deps.setRecipeEnabledFn) {
|
|
607
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
608
|
+
res.end(JSON.stringify({ ok: false, error: "Not available" }));
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const result = deps.setRecipeEnabledFn(name, body.enabled);
|
|
612
|
+
res.writeHead(result.ok ? 200 : 400, {
|
|
613
|
+
"Content-Type": "application/json",
|
|
614
|
+
});
|
|
615
|
+
res.end(JSON.stringify(result));
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
respondInvalidJson(res);
|
|
619
|
+
}
|
|
620
|
+
})();
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
if (parsedUrl.pathname === "/recipes/lint" && req.method === "POST") {
|
|
624
|
+
// A-PR2: bounded JSON read at RECIPE_ROUTE_BODY_CAPS.content (256 KB).
|
|
625
|
+
void (async () => {
|
|
626
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.content);
|
|
627
|
+
if (!parsedBody.ok) {
|
|
628
|
+
if (parsedBody.code === "too_large") {
|
|
629
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.content);
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
respondInvalidJson(res);
|
|
633
|
+
}
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
const body = parsedBody.value ?? {};
|
|
638
|
+
if (typeof body?.content !== "string") {
|
|
639
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
640
|
+
res.end(JSON.stringify({
|
|
641
|
+
ok: false,
|
|
642
|
+
error: "content (string) required",
|
|
643
|
+
}));
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (!deps.lintRecipeContentFn) {
|
|
647
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
648
|
+
res.end(JSON.stringify({
|
|
649
|
+
ok: false,
|
|
650
|
+
error: "Recipe lint unavailable",
|
|
651
|
+
}));
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const result = deps.lintRecipeContentFn(body.content);
|
|
655
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
656
|
+
res.end(JSON.stringify(result));
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
respondInvalidJson(res);
|
|
660
|
+
}
|
|
661
|
+
})();
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
// GET /recipes/:name/plan — dry-run plan for a recipe by name. Returns the
|
|
665
|
+
// same RecipeDryRunPlan shape as GET /runs/:seq/plan but without needing a
|
|
666
|
+
// past run seq — useful for pre-flight review before a first run.
|
|
667
|
+
const recipePlanMatch = req.method === "GET"
|
|
668
|
+
? /^\/recipes\/([^/]+)\/plan$/.exec(parsedUrl.pathname)
|
|
669
|
+
: null;
|
|
670
|
+
if (recipePlanMatch?.[1]) {
|
|
671
|
+
const name = decodeURIComponent(recipePlanMatch[1]);
|
|
672
|
+
void (async () => {
|
|
673
|
+
try {
|
|
674
|
+
if (!deps.runPlanFn) {
|
|
675
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
676
|
+
res.end(JSON.stringify({ error: "plan_unavailable" }));
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const plan = await deps.runPlanFn(name);
|
|
680
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
681
|
+
res.end(JSON.stringify({ plan }));
|
|
682
|
+
}
|
|
683
|
+
catch (err) {
|
|
684
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
685
|
+
const status = msg.includes("not found") || msg.includes("ENOENT") ? 404 : 500;
|
|
686
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
687
|
+
res.end(JSON.stringify({ error: msg }));
|
|
688
|
+
}
|
|
689
|
+
})();
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
const recipeContentMatch = /^\/recipes\/([^/]+)$/.exec(parsedUrl.pathname);
|
|
693
|
+
if (recipeContentMatch && req.method === "GET") {
|
|
694
|
+
const name = decodeURIComponent(recipeContentMatch[1] ?? "");
|
|
695
|
+
if (!deps.loadRecipeContentFn) {
|
|
696
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
697
|
+
res.end(JSON.stringify({ ok: false, error: "Recipe content unavailable" }));
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
const result = deps.loadRecipeContentFn(name);
|
|
701
|
+
if (!result) {
|
|
702
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
703
|
+
res.end(JSON.stringify({ ok: false, error: "Recipe not found" }));
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
707
|
+
res.end(JSON.stringify(result));
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
if (recipeContentMatch && req.method === "PUT") {
|
|
711
|
+
// A-PR2: bounded JSON read at RECIPE_ROUTE_BODY_CAPS.content (256 KB).
|
|
712
|
+
const name = decodeURIComponent(recipeContentMatch[1] ?? "");
|
|
713
|
+
void (async () => {
|
|
714
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.content);
|
|
715
|
+
if (!parsedBody.ok) {
|
|
716
|
+
if (parsedBody.code === "too_large") {
|
|
717
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.content);
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
respondInvalidJson(res);
|
|
721
|
+
}
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
const body = parsedBody.value ?? {};
|
|
726
|
+
if (typeof body.content !== "string") {
|
|
727
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
728
|
+
res.end(JSON.stringify({
|
|
729
|
+
ok: false,
|
|
730
|
+
error: "content (string) required",
|
|
731
|
+
}));
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (!deps.saveRecipeContentFn) {
|
|
735
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
736
|
+
res.end(JSON.stringify({
|
|
737
|
+
ok: false,
|
|
738
|
+
error: "Recipe content saving unavailable",
|
|
739
|
+
}));
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const result = deps.saveRecipeContentFn(name, body.content);
|
|
743
|
+
res.writeHead(result.ok ? 200 : 400, {
|
|
744
|
+
"Content-Type": "application/json",
|
|
745
|
+
});
|
|
746
|
+
res.end(JSON.stringify(result));
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
respondInvalidJson(res);
|
|
750
|
+
}
|
|
751
|
+
})();
|
|
752
|
+
return true;
|
|
753
|
+
}
|
|
754
|
+
if (recipeContentMatch && req.method === "DELETE") {
|
|
755
|
+
const name = decodeURIComponent(recipeContentMatch[1] ?? "");
|
|
756
|
+
if (!deps.deleteRecipeContentFn) {
|
|
757
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
758
|
+
res.end(JSON.stringify({
|
|
759
|
+
ok: false,
|
|
760
|
+
error: "Recipe deletion unavailable",
|
|
761
|
+
}));
|
|
762
|
+
return true;
|
|
763
|
+
}
|
|
764
|
+
const result = deps.deleteRecipeContentFn(name);
|
|
765
|
+
const status = result.ok
|
|
766
|
+
? 200
|
|
767
|
+
: result.error === "Recipe not found"
|
|
768
|
+
? 404
|
|
769
|
+
: 400;
|
|
770
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
771
|
+
res.end(JSON.stringify(result));
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
// POST /recipes/:name/duplicate — copy recipe as next available variant name
|
|
775
|
+
const duplicateMatch = /^\/recipes\/([^/]+)\/duplicate$/.exec(parsedUrl.pathname);
|
|
776
|
+
if (duplicateMatch && req.method === "POST") {
|
|
777
|
+
const name = decodeURIComponent(duplicateMatch[1] ?? "");
|
|
778
|
+
if (!deps.duplicateRecipeFn) {
|
|
779
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
780
|
+
res.end(JSON.stringify({ ok: false, error: "Duplicate unavailable" }));
|
|
781
|
+
return true;
|
|
782
|
+
}
|
|
783
|
+
const result = deps.duplicateRecipeFn(name);
|
|
784
|
+
const status = result.ok
|
|
785
|
+
? 201
|
|
786
|
+
: result.error === "Recipe not found"
|
|
787
|
+
? 404
|
|
788
|
+
: 400;
|
|
789
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
790
|
+
res.end(JSON.stringify(result));
|
|
791
|
+
return true;
|
|
792
|
+
}
|
|
793
|
+
// POST /recipes/:name/promote — promote variant to canonical name.
|
|
794
|
+
// Body: { targetName: string }
|
|
795
|
+
const promoteMatch = /^\/recipes\/([^/]+)\/promote$/.exec(parsedUrl.pathname);
|
|
796
|
+
if (promoteMatch && req.method === "POST") {
|
|
797
|
+
const variantName = decodeURIComponent(promoteMatch[1] ?? "");
|
|
798
|
+
void (async () => {
|
|
799
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.content);
|
|
800
|
+
if (!parsedBody.ok) {
|
|
801
|
+
respondInvalidJson(res);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const { targetName, force } = parsedBody.value ?? {};
|
|
805
|
+
if (typeof targetName !== "string" || !targetName.trim()) {
|
|
806
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
807
|
+
res.end(JSON.stringify({ ok: false, error: "targetName required" }));
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (!deps.promoteRecipeVariantFn) {
|
|
811
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
812
|
+
res.end(JSON.stringify({ ok: false, error: "Promote unavailable" }));
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
try {
|
|
816
|
+
const result = await deps.promoteRecipeVariantFn(variantName, targetName, {
|
|
817
|
+
force: force === true,
|
|
818
|
+
});
|
|
819
|
+
const httpStatus = result.ok
|
|
820
|
+
? 200
|
|
821
|
+
: result.targetExists
|
|
822
|
+
? 409
|
|
823
|
+
: result.error?.includes("not found")
|
|
824
|
+
? 404
|
|
825
|
+
: 400;
|
|
826
|
+
res.writeHead(httpStatus, { "Content-Type": "application/json" });
|
|
827
|
+
res.end(JSON.stringify(result));
|
|
828
|
+
}
|
|
829
|
+
catch (err) {
|
|
830
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
831
|
+
res.end(JSON.stringify({
|
|
832
|
+
ok: false,
|
|
833
|
+
error: err instanceof Error ? err.message : String(err),
|
|
834
|
+
}));
|
|
835
|
+
}
|
|
836
|
+
})();
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
if (req.url === "/recipes" && req.method === "GET") {
|
|
840
|
+
try {
|
|
841
|
+
const data = deps.recipesFn?.() ?? { recipesDir: null, recipes: [] };
|
|
842
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
843
|
+
res.end(JSON.stringify(data));
|
|
844
|
+
}
|
|
845
|
+
catch (err) {
|
|
846
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
847
|
+
res.end(JSON.stringify({
|
|
848
|
+
error: err instanceof Error ? err.message : String(err),
|
|
849
|
+
}));
|
|
850
|
+
}
|
|
851
|
+
return true;
|
|
852
|
+
}
|
|
853
|
+
if (parsedUrl.pathname === "/templates" && req.method === "GET") {
|
|
854
|
+
void (async () => {
|
|
855
|
+
try {
|
|
856
|
+
const now = Date.now();
|
|
857
|
+
if (!templatesCache || now - templatesCacheTs > 5 * 60 * 1000) {
|
|
858
|
+
const ghRes = await fetch("https://raw.githubusercontent.com/patchworkos/recipes/main/index.json");
|
|
859
|
+
if (!ghRes.ok) {
|
|
860
|
+
throw new Error(`GitHub returned ${ghRes.status}`);
|
|
861
|
+
}
|
|
862
|
+
templatesCache = (await ghRes.json());
|
|
863
|
+
templatesCacheTs = now;
|
|
864
|
+
}
|
|
865
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
866
|
+
res.end(JSON.stringify(templatesCache));
|
|
867
|
+
}
|
|
868
|
+
catch (err) {
|
|
869
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
870
|
+
res.end(JSON.stringify({
|
|
871
|
+
ok: false,
|
|
872
|
+
error: err instanceof Error ? err.message : String(err),
|
|
873
|
+
}));
|
|
874
|
+
}
|
|
875
|
+
})();
|
|
876
|
+
return true;
|
|
877
|
+
}
|
|
878
|
+
if (parsedUrl.pathname === "/recipes/install" && req.method === "POST") {
|
|
879
|
+
// ---------------------------------------------------------------------
|
|
880
|
+
// BEGIN A-PR2 EDIT BLOCK — `/recipes/install` rework.
|
|
881
|
+
//
|
|
882
|
+
// Replaces the previous let-body-string accumulator with `readJsonBody`
|
|
883
|
+
// (4 KB cap), default-denies non-github sources via
|
|
884
|
+
// `CLAUDE_IDE_BRIDGE_INSTALL_ALLOWED_HOSTS`, and translates fetch errors
|
|
885
|
+
// into proper 4xx status codes (R2 H-routes Bug 2 — was always 500).
|
|
886
|
+
//
|
|
887
|
+
// SSRF guard runs AFTER allowlist match per R3 DP-2 sub-issue: this means
|
|
888
|
+
// an explicitly-allowlisted hostname STILL has to clear the SSRF check
|
|
889
|
+
// (so an admin can't accidentally allowlist `localhost`).
|
|
890
|
+
// ---------------------------------------------------------------------
|
|
891
|
+
void (async () => {
|
|
892
|
+
const parsedBody = await readJsonBody(req, RECIPE_ROUTE_BODY_CAPS.install);
|
|
893
|
+
if (!parsedBody.ok) {
|
|
894
|
+
if (parsedBody.code === "too_large") {
|
|
895
|
+
respond413(res, RECIPE_ROUTE_BODY_CAPS.install);
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
respondInvalidJson(res);
|
|
899
|
+
}
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
try {
|
|
903
|
+
const source = parsedBody.value?.source;
|
|
904
|
+
if (!source || typeof source !== "string") {
|
|
905
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
906
|
+
res.end(JSON.stringify({ ok: false, error: "Missing source field" }));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
const githubPrefix = "github:patchworkos/recipes/recipes/";
|
|
910
|
+
let fetchUrl;
|
|
911
|
+
let recipeName;
|
|
912
|
+
if (source.startsWith(githubPrefix)) {
|
|
913
|
+
recipeName = source.slice(githubPrefix.length);
|
|
914
|
+
// The constructed URL is internal — recipeName must be a safe
|
|
915
|
+
// single-segment so we don't end up encoding `../etc/passwd` into
|
|
916
|
+
// the path. Reuse the strict basename predicate from `recipeInstall`.
|
|
917
|
+
const { isSafeBasename } = await import("./commands/recipeInstall.js");
|
|
918
|
+
if (!isSafeBasename(recipeName)) {
|
|
919
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
920
|
+
res.end(JSON.stringify({
|
|
921
|
+
ok: false,
|
|
922
|
+
error: "Invalid recipe name in source",
|
|
923
|
+
}));
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
fetchUrl = `https://raw.githubusercontent.com/patchworkos/recipes/main/recipes/${recipeName}/${recipeName}.yaml`;
|
|
927
|
+
}
|
|
928
|
+
else if (source.startsWith("https://")) {
|
|
929
|
+
// Non-github source: must clear the env-var allowlist AND the SSRF
|
|
930
|
+
// guard. Default-deny when env var unset (R3 DP-2 confirmed).
|
|
931
|
+
let parsedSource;
|
|
932
|
+
try {
|
|
933
|
+
parsedSource = new URL(source);
|
|
934
|
+
}
|
|
935
|
+
catch {
|
|
936
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
937
|
+
res.end(JSON.stringify({
|
|
938
|
+
ok: false,
|
|
939
|
+
error: "Invalid source URL",
|
|
940
|
+
code: "invalid_source_url",
|
|
941
|
+
}));
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
// Built-in github/raw.githubusercontent hosts are always permitted
|
|
945
|
+
// — they match the github: shorthand surface above.
|
|
946
|
+
const ALWAYS_ALLOWED = new Set([
|
|
947
|
+
"github.com",
|
|
948
|
+
"www.github.com",
|
|
949
|
+
"raw.githubusercontent.com",
|
|
950
|
+
]);
|
|
951
|
+
const envAllowed = (process.env.CLAUDE_IDE_BRIDGE_INSTALL_ALLOWED_HOSTS ?? "")
|
|
952
|
+
.split(",")
|
|
953
|
+
.map((h) => h.trim().toLowerCase())
|
|
954
|
+
.filter(Boolean);
|
|
955
|
+
const hostLower = parsedSource.hostname.toLowerCase();
|
|
956
|
+
const inAllowlist = ALWAYS_ALLOWED.has(hostLower) || envAllowed.includes(hostLower);
|
|
957
|
+
if (!inAllowlist) {
|
|
958
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
959
|
+
res.end(JSON.stringify({
|
|
960
|
+
ok: false,
|
|
961
|
+
error: `Host "${parsedSource.hostname}" is not in the install allowlist. Set CLAUDE_IDE_BRIDGE_INSTALL_ALLOWED_HOSTS to opt in.`,
|
|
962
|
+
code: "host_not_allowlisted",
|
|
963
|
+
}));
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
// SSRF guard runs AFTER allowlist — defends against operator-misuse
|
|
967
|
+
// (allowlisting localhost or an internal mirror).
|
|
968
|
+
const ssrf = await validateSafeUrl(source);
|
|
969
|
+
if (!ssrf.ok) {
|
|
970
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
971
|
+
res.end(JSON.stringify({
|
|
972
|
+
ok: false,
|
|
973
|
+
error: `Host blocked by SSRF guard: ${ssrf.detail ?? ssrf.reason ?? "unknown"}`,
|
|
974
|
+
code: "ssrf_blocked",
|
|
975
|
+
}));
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
fetchUrl = source;
|
|
979
|
+
const urlParts = fetchUrl.split("/");
|
|
980
|
+
recipeName = (urlParts[urlParts.length - 1] ?? "recipe").replace(/\.ya?ml$/i, "");
|
|
981
|
+
}
|
|
982
|
+
else {
|
|
983
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
984
|
+
res.end(JSON.stringify({
|
|
985
|
+
ok: false,
|
|
986
|
+
error: "Unsupported source format",
|
|
987
|
+
code: "unsupported_source",
|
|
988
|
+
}));
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
// Bounded fetch — 1 MB hard cap on the response body so a malicious
|
|
992
|
+
// host can't pin the install request open with a 1 GB stream.
|
|
993
|
+
const fetchCtl = new AbortController();
|
|
994
|
+
const fetchTimeout = setTimeout(() => fetchCtl.abort(), 30_000);
|
|
995
|
+
let yamlRes;
|
|
996
|
+
try {
|
|
997
|
+
yamlRes = await fetch(fetchUrl, {
|
|
998
|
+
signal: fetchCtl.signal,
|
|
999
|
+
redirect: "follow",
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
catch (err) {
|
|
1003
|
+
clearTimeout(fetchTimeout);
|
|
1004
|
+
// Network-level error → 502 (upstream unreachable), not 500.
|
|
1005
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1006
|
+
res.end(JSON.stringify({
|
|
1007
|
+
ok: false,
|
|
1008
|
+
error: `Fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1009
|
+
code: "fetch_network_error",
|
|
1010
|
+
}));
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
clearTimeout(fetchTimeout);
|
|
1014
|
+
if (!yamlRes.ok) {
|
|
1015
|
+
// Translate upstream HTTP into proper status — 404→404, 403→403,
|
|
1016
|
+
// 5xx→502 (don't leak the upstream 500 as our 500). R2 H-routes Bug 2.
|
|
1017
|
+
let outStatus = 502;
|
|
1018
|
+
if (yamlRes.status === 404)
|
|
1019
|
+
outStatus = 404;
|
|
1020
|
+
else if (yamlRes.status === 403)
|
|
1021
|
+
outStatus = 403;
|
|
1022
|
+
else if (yamlRes.status >= 400 && yamlRes.status < 500)
|
|
1023
|
+
outStatus = 400;
|
|
1024
|
+
res.writeHead(outStatus, { "Content-Type": "application/json" });
|
|
1025
|
+
res.end(JSON.stringify({
|
|
1026
|
+
ok: false,
|
|
1027
|
+
error: `Upstream returned ${yamlRes.status} ${yamlRes.statusText}`,
|
|
1028
|
+
code: "fetch_upstream_error",
|
|
1029
|
+
upstreamStatus: yamlRes.status,
|
|
1030
|
+
}));
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
// Streamed read with 1 MB cap (mirrors `httpClient` pattern).
|
|
1034
|
+
const MAX_RECIPE_BYTES = 1024 * 1024;
|
|
1035
|
+
const reader = yamlRes.body?.getReader();
|
|
1036
|
+
const chunks = [];
|
|
1037
|
+
let totalBytes = 0;
|
|
1038
|
+
let truncated = false;
|
|
1039
|
+
if (reader) {
|
|
1040
|
+
try {
|
|
1041
|
+
while (true) {
|
|
1042
|
+
const { done, value } = await reader.read();
|
|
1043
|
+
if (done || value === undefined)
|
|
1044
|
+
break;
|
|
1045
|
+
if (totalBytes + value.byteLength > MAX_RECIPE_BYTES) {
|
|
1046
|
+
truncated = true;
|
|
1047
|
+
await reader.cancel();
|
|
1048
|
+
break;
|
|
1049
|
+
}
|
|
1050
|
+
chunks.push(value);
|
|
1051
|
+
totalBytes += value.byteLength;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
finally {
|
|
1055
|
+
reader.releaseLock();
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
if (truncated) {
|
|
1059
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
1060
|
+
res.end(JSON.stringify({
|
|
1061
|
+
ok: false,
|
|
1062
|
+
error: `Recipe body exceeded ${MAX_RECIPE_BYTES}-byte limit`,
|
|
1063
|
+
code: "recipe_too_large",
|
|
1064
|
+
}));
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
const yamlText = Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf-8");
|
|
1068
|
+
const tmpFile = path.join(os.tmpdir(), `patchwork-install-${Date.now()}-${recipeName}.yaml`);
|
|
1069
|
+
const { writeFileSync, mkdirSync, unlinkSync } = await import("node:fs");
|
|
1070
|
+
writeFileSync(tmpFile, yamlText, "utf-8");
|
|
1071
|
+
let result;
|
|
1072
|
+
try {
|
|
1073
|
+
const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
|
|
1074
|
+
mkdirSync(recipesDir, { recursive: true });
|
|
1075
|
+
const { installRecipeFromFile } = await import("./recipes/installer.js");
|
|
1076
|
+
const installResult = installRecipeFromFile(tmpFile, {
|
|
1077
|
+
recipesDir,
|
|
1078
|
+
});
|
|
1079
|
+
result = { action: installResult.action, name: recipeName };
|
|
1080
|
+
}
|
|
1081
|
+
finally {
|
|
1082
|
+
try {
|
|
1083
|
+
unlinkSync(tmpFile);
|
|
1084
|
+
}
|
|
1085
|
+
catch {
|
|
1086
|
+
// best-effort cleanup
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1090
|
+
res.end(JSON.stringify({ ok: true, ...result }));
|
|
1091
|
+
}
|
|
1092
|
+
catch (err) {
|
|
1093
|
+
// Truly unexpected — installer crash, manifest validation throw, etc.
|
|
1094
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1095
|
+
res.end(JSON.stringify({
|
|
1096
|
+
ok: false,
|
|
1097
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1098
|
+
code: "install_internal_error",
|
|
1099
|
+
}));
|
|
1100
|
+
}
|
|
1101
|
+
})();
|
|
1102
|
+
// END A-PR2 EDIT BLOCK
|
|
1103
|
+
return true;
|
|
1104
|
+
}
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
//# sourceMappingURL=recipeRoutes.js.map
|