rowbound 1.0.2
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/LICENSE +21 -0
- package/README.md +258 -0
- package/dist/adapters/adapter.d.ts +1 -0
- package/dist/adapters/adapter.js +1 -0
- package/dist/adapters/sheets/sheets-adapter.d.ts +66 -0
- package/dist/adapters/sheets/sheets-adapter.js +531 -0
- package/dist/cli/config.d.ts +2 -0
- package/dist/cli/config.js +397 -0
- package/dist/cli/env.d.ts +3 -0
- package/dist/cli/env.js +103 -0
- package/dist/cli/format.d.ts +5 -0
- package/dist/cli/format.js +6 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +39 -0
- package/dist/cli/init.d.ts +10 -0
- package/dist/cli/init.js +72 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.js +212 -0
- package/dist/cli/runs.d.ts +2 -0
- package/dist/cli/runs.js +108 -0
- package/dist/cli/status.d.ts +2 -0
- package/dist/cli/status.js +108 -0
- package/dist/cli/sync.d.ts +2 -0
- package/dist/cli/sync.js +84 -0
- package/dist/cli/watch.d.ts +2 -0
- package/dist/cli/watch.js +348 -0
- package/dist/core/condition.d.ts +25 -0
- package/dist/core/condition.js +66 -0
- package/dist/core/defaults.d.ts +3 -0
- package/dist/core/defaults.js +7 -0
- package/dist/core/engine.d.ts +50 -0
- package/dist/core/engine.js +234 -0
- package/dist/core/env.d.ts +13 -0
- package/dist/core/env.js +72 -0
- package/dist/core/exec.d.ts +24 -0
- package/dist/core/exec.js +134 -0
- package/dist/core/extractor.d.ts +10 -0
- package/dist/core/extractor.js +33 -0
- package/dist/core/http-client.d.ts +32 -0
- package/dist/core/http-client.js +161 -0
- package/dist/core/rate-limiter.d.ts +25 -0
- package/dist/core/rate-limiter.js +64 -0
- package/dist/core/reconcile.d.ts +24 -0
- package/dist/core/reconcile.js +192 -0
- package/dist/core/run-format.d.ts +39 -0
- package/dist/core/run-format.js +201 -0
- package/dist/core/run-state.d.ts +64 -0
- package/dist/core/run-state.js +141 -0
- package/dist/core/run-tracker.d.ts +15 -0
- package/dist/core/run-tracker.js +57 -0
- package/dist/core/safe-compare.d.ts +8 -0
- package/dist/core/safe-compare.js +19 -0
- package/dist/core/shell-escape.d.ts +7 -0
- package/dist/core/shell-escape.js +9 -0
- package/dist/core/tab-resolver.d.ts +17 -0
- package/dist/core/tab-resolver.js +44 -0
- package/dist/core/template.d.ts +32 -0
- package/dist/core/template.js +82 -0
- package/dist/core/types.d.ts +105 -0
- package/dist/core/types.js +2 -0
- package/dist/core/url-guard.d.ts +21 -0
- package/dist/core/url-guard.js +184 -0
- package/dist/core/validator.d.ts +11 -0
- package/dist/core/validator.js +261 -0
- package/dist/core/waterfall.d.ts +26 -0
- package/dist/core/waterfall.js +55 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +16 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +943 -0
- package/package.json +67 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import vm from "node:vm";
|
|
2
|
+
import { evaluateCondition, preCheckExpression } from "./condition.js";
|
|
3
|
+
import { executeExecAction } from "./exec.js";
|
|
4
|
+
import { extractValue } from "./extractor.js";
|
|
5
|
+
import { httpRequest } from "./http-client.js";
|
|
6
|
+
import { RateLimiter } from "./rate-limiter.js";
|
|
7
|
+
import { resolveObject, resolveTemplate, } from "./template.js";
|
|
8
|
+
import { executeWaterfall } from "./waterfall.js";
|
|
9
|
+
/**
|
|
10
|
+
* Evaluate a JavaScript expression in a sandboxed context, returning the result as a string.
|
|
11
|
+
*
|
|
12
|
+
* WARNING: Node.js vm module is NOT a security boundary. The pre-check
|
|
13
|
+
* and Object.create(null) sandbox are defense-in-depth measures only.
|
|
14
|
+
* Do not rely on this for untrusted code execution.
|
|
15
|
+
*
|
|
16
|
+
* Unlike evaluateCondition (which coerces to boolean), this returns the raw value
|
|
17
|
+
* stringified — used for transform action expressions.
|
|
18
|
+
*/
|
|
19
|
+
export function evaluateExpression(expression, context) {
|
|
20
|
+
preCheckExpression(expression);
|
|
21
|
+
const rawSandbox = Object.create(null);
|
|
22
|
+
rawSandbox.row = { ...context.row };
|
|
23
|
+
rawSandbox.env = context.env;
|
|
24
|
+
rawSandbox.results = context.results ?? {};
|
|
25
|
+
const sandbox = vm.createContext(rawSandbox);
|
|
26
|
+
const result = vm.runInContext(expression, sandbox, { timeout: 100 });
|
|
27
|
+
if (result === undefined || result === null) {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
if (typeof result === "object") {
|
|
31
|
+
return JSON.stringify(result);
|
|
32
|
+
}
|
|
33
|
+
return String(result);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Parse a range string like "2:50" into start/end indices (0-based data row indices).
|
|
37
|
+
* Range uses sheet row numbers (1-indexed, row 1 = headers, row 2 = first data row).
|
|
38
|
+
* So range "2:50" means data rows 0..48.
|
|
39
|
+
*/
|
|
40
|
+
function parseRange(range, totalRows) {
|
|
41
|
+
if (!range) {
|
|
42
|
+
return { start: 0, end: totalRows };
|
|
43
|
+
}
|
|
44
|
+
const parts = range.split(":");
|
|
45
|
+
if (parts.length !== 2) {
|
|
46
|
+
throw new Error(`Invalid range "${range}": expected format "start:end" (e.g. "2:50")`);
|
|
47
|
+
}
|
|
48
|
+
const sheetStart = parseInt(parts[0], 10);
|
|
49
|
+
const sheetEnd = parseInt(parts[1], 10);
|
|
50
|
+
if (Number.isNaN(sheetStart) || Number.isNaN(sheetEnd)) {
|
|
51
|
+
throw new Error(`Invalid range "${range}": start and end must be numbers`);
|
|
52
|
+
}
|
|
53
|
+
if (sheetStart < 1) {
|
|
54
|
+
throw new Error(`Invalid range "${range}": start must be >= 1 (got ${sheetStart})`);
|
|
55
|
+
}
|
|
56
|
+
if (sheetStart > sheetEnd) {
|
|
57
|
+
throw new Error(`Invalid range "${range}": start (${sheetStart}) must be <= end (${sheetEnd})`);
|
|
58
|
+
}
|
|
59
|
+
// Sheet row 2 = data row 0, sheet row 3 = data row 1, etc.
|
|
60
|
+
const start = Math.max(0, sheetStart - 2);
|
|
61
|
+
const end = Math.min(totalRows, sheetEnd - 1);
|
|
62
|
+
return { start, end };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Execute an HTTP action: resolve templates, make request, extract value.
|
|
66
|
+
*/
|
|
67
|
+
async function executeHttpAction(action, context, rateLimiter, retryAttempts, signal, retryBackoff, onMissing) {
|
|
68
|
+
const resolvedUrl = resolveTemplate(action.url, context, onMissing);
|
|
69
|
+
const resolvedHeaders = action.headers
|
|
70
|
+
? resolveObject(action.headers, context, onMissing)
|
|
71
|
+
: undefined;
|
|
72
|
+
const resolvedBody = action.body !== undefined
|
|
73
|
+
? resolveObject(action.body, context, onMissing)
|
|
74
|
+
: undefined;
|
|
75
|
+
const response = await httpRequest({
|
|
76
|
+
method: action.method,
|
|
77
|
+
url: resolvedUrl,
|
|
78
|
+
headers: resolvedHeaders,
|
|
79
|
+
body: resolvedBody,
|
|
80
|
+
retryAttempts,
|
|
81
|
+
retryBackoff,
|
|
82
|
+
onError: action.onError,
|
|
83
|
+
rateLimiter,
|
|
84
|
+
signal,
|
|
85
|
+
});
|
|
86
|
+
if (response === null) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const value = extractValue(response.data, action.extract);
|
|
90
|
+
return value !== "" ? value : null;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Run the full pipeline: read rows, process each through actions, write results back.
|
|
94
|
+
*
|
|
95
|
+
* Execution flow per row:
|
|
96
|
+
* 1. Read row data (header -> value map)
|
|
97
|
+
* 2. For each action: evaluate condition, execute, update in-memory row state
|
|
98
|
+
* 3. Batch-write all cell updates for the row
|
|
99
|
+
* 4. Fire progress callbacks
|
|
100
|
+
*/
|
|
101
|
+
export async function runPipeline(options) {
|
|
102
|
+
const { adapter, ref, config, env, range, actionFilter, dryRun = false, signal, } = options;
|
|
103
|
+
// Read all rows from the sheet
|
|
104
|
+
const rows = await adapter.readRows(ref);
|
|
105
|
+
// Create rate limiter if configured
|
|
106
|
+
const rateLimiter = config.settings.rateLimit > 0
|
|
107
|
+
? new RateLimiter(config.settings.rateLimit)
|
|
108
|
+
: undefined;
|
|
109
|
+
const retryAttempts = config.settings.retryAttempts ?? 0;
|
|
110
|
+
const retryBackoff = config.settings.retryBackoff;
|
|
111
|
+
// Deduplicate missing-variable warnings (warn once per unique source.key)
|
|
112
|
+
const warnedMissing = new Set();
|
|
113
|
+
const onMissing = (source, key) => {
|
|
114
|
+
const tag = `${source}.${key}`;
|
|
115
|
+
if (!warnedMissing.has(tag)) {
|
|
116
|
+
warnedMissing.add(tag);
|
|
117
|
+
console.warn(`Warning: template variable {{${tag}}} resolved to empty string (not found in context)`);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
// Determine which actions to run
|
|
121
|
+
const actions = actionFilter
|
|
122
|
+
? config.actions.filter((s) => s.id === actionFilter)
|
|
123
|
+
: config.actions;
|
|
124
|
+
// Parse range
|
|
125
|
+
const { start, end } = parseRange(range, rows.length);
|
|
126
|
+
// Notify caller of total rows to process (for progress display)
|
|
127
|
+
options.onTotalRows?.(end - start);
|
|
128
|
+
const result = {
|
|
129
|
+
totalRows: rows.length,
|
|
130
|
+
processedRows: 0,
|
|
131
|
+
skippedRows: 0,
|
|
132
|
+
errors: [],
|
|
133
|
+
updates: 0,
|
|
134
|
+
};
|
|
135
|
+
// Warn if concurrency > 1 since it's not yet implemented
|
|
136
|
+
if (config.settings.concurrency > 1) {
|
|
137
|
+
console.warn(`Warning: concurrency is set to ${config.settings.concurrency} but parallel row processing is not yet implemented. All rows will be processed sequentially (concurrency=1).`);
|
|
138
|
+
}
|
|
139
|
+
for (let i = start; i < end; i++) {
|
|
140
|
+
// Check abort signal between rows
|
|
141
|
+
if (signal?.aborted) {
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
// Build ID-keyed row from name-keyed sheet data
|
|
145
|
+
const nameKeyedRow = rows[i];
|
|
146
|
+
const row = {};
|
|
147
|
+
if (options.columnMap) {
|
|
148
|
+
for (const [id, name] of Object.entries(options.columnMap)) {
|
|
149
|
+
if (nameKeyedRow[name] !== undefined) {
|
|
150
|
+
row[id] = nameKeyedRow[name];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
// No column map — use name-keyed row directly (legacy/testing)
|
|
156
|
+
Object.assign(row, nameKeyedRow);
|
|
157
|
+
}
|
|
158
|
+
const rowUpdates = [];
|
|
159
|
+
options.onRowStart?.(i, row);
|
|
160
|
+
const context = { row, env };
|
|
161
|
+
for (const action of actions) {
|
|
162
|
+
// Check abort between actions (not just between rows)
|
|
163
|
+
if (signal?.aborted)
|
|
164
|
+
break;
|
|
165
|
+
try {
|
|
166
|
+
// Skip if target cell already has a value
|
|
167
|
+
if (row[action.target] !== undefined && row[action.target] !== "") {
|
|
168
|
+
options.onActionComplete?.(i, action.id, null);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
// Evaluate `when` condition
|
|
172
|
+
if (!evaluateCondition(action.when, context)) {
|
|
173
|
+
options.onActionComplete?.(i, action.id, null);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
let value = null;
|
|
177
|
+
if (action.type === "transform") {
|
|
178
|
+
value = evaluateExpression(action.expression, context);
|
|
179
|
+
}
|
|
180
|
+
else if (action.type === "http") {
|
|
181
|
+
value = await executeHttpAction(action, context, rateLimiter, retryAttempts, signal, retryBackoff, onMissing);
|
|
182
|
+
}
|
|
183
|
+
else if (action.type === "waterfall") {
|
|
184
|
+
const waterfallResult = await executeWaterfall(action, context, {
|
|
185
|
+
rateLimiter,
|
|
186
|
+
retryAttempts,
|
|
187
|
+
retryBackoff,
|
|
188
|
+
signal,
|
|
189
|
+
onMissing,
|
|
190
|
+
});
|
|
191
|
+
value = waterfallResult?.value ?? null;
|
|
192
|
+
}
|
|
193
|
+
else if (action.type === "exec") {
|
|
194
|
+
value = await executeExecAction(action, context, {
|
|
195
|
+
signal,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (value !== null) {
|
|
199
|
+
// Update in-memory row so subsequent actions see new values (ID-keyed)
|
|
200
|
+
row[action.target] = value;
|
|
201
|
+
// Resolve target ID to column name for sheet write
|
|
202
|
+
const columnName = options.columnMap?.[action.target] ?? action.target;
|
|
203
|
+
// Sheet row = data index + 2 (row 1 is headers)
|
|
204
|
+
rowUpdates.push({
|
|
205
|
+
row: i + 2,
|
|
206
|
+
column: columnName,
|
|
207
|
+
value,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
options.onActionComplete?.(i, action.id, value);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
214
|
+
result.errors.push({
|
|
215
|
+
rowIndex: i,
|
|
216
|
+
actionId: action.id,
|
|
217
|
+
error: err.message,
|
|
218
|
+
});
|
|
219
|
+
options.onError?.(i, action.id, err);
|
|
220
|
+
options.onActionComplete?.(i, action.id, null);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Write batch for this row
|
|
224
|
+
if (rowUpdates.length > 0 && !dryRun) {
|
|
225
|
+
await adapter.writeBatch(ref, rowUpdates);
|
|
226
|
+
}
|
|
227
|
+
result.updates += rowUpdates.length;
|
|
228
|
+
result.processedRows++;
|
|
229
|
+
options.onRowComplete?.(i, rowUpdates);
|
|
230
|
+
}
|
|
231
|
+
// Count skipped rows (rows outside the range)
|
|
232
|
+
result.skippedRows = rows.length - result.processedRows;
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PipelineConfig } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Build a filtered environment object that only includes safe variables.
|
|
4
|
+
*
|
|
5
|
+
* Instead of leaking all of process.env into the pipeline context, this
|
|
6
|
+
* function constructs a minimal env by:
|
|
7
|
+
* 1. Including all ROWBOUND_* prefixed vars
|
|
8
|
+
* 2. Scanning config template strings for {{env.X}} references and
|
|
9
|
+
* including those specific keys from process.env
|
|
10
|
+
* 3. Including NODE_ENV if set
|
|
11
|
+
* 4. Including PATH so child processes can find executables
|
|
12
|
+
*/
|
|
13
|
+
export declare function buildSafeEnv(config?: PipelineConfig): Record<string, string>;
|
package/dist/core/env.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a filtered environment object that only includes safe variables.
|
|
3
|
+
*
|
|
4
|
+
* Instead of leaking all of process.env into the pipeline context, this
|
|
5
|
+
* function constructs a minimal env by:
|
|
6
|
+
* 1. Including all ROWBOUND_* prefixed vars
|
|
7
|
+
* 2. Scanning config template strings for {{env.X}} references and
|
|
8
|
+
* including those specific keys from process.env
|
|
9
|
+
* 3. Including NODE_ENV if set
|
|
10
|
+
* 4. Including PATH so child processes can find executables
|
|
11
|
+
*/
|
|
12
|
+
export function buildSafeEnv(config) {
|
|
13
|
+
const env = {};
|
|
14
|
+
// 1. ROWBOUND_* prefixed vars
|
|
15
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
16
|
+
if (value !== undefined && key.startsWith("ROWBOUND_")) {
|
|
17
|
+
env[key] = value;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// 2. NODE_ENV
|
|
21
|
+
if (process.env.NODE_ENV !== undefined) {
|
|
22
|
+
env.NODE_ENV = process.env.NODE_ENV;
|
|
23
|
+
}
|
|
24
|
+
// 3. PATH so child processes can find executables
|
|
25
|
+
if (process.env.PATH !== undefined) {
|
|
26
|
+
env.PATH = process.env.PATH;
|
|
27
|
+
}
|
|
28
|
+
// 4. Scan config templates for {{env.X}} references
|
|
29
|
+
if (config) {
|
|
30
|
+
const referencedKeys = extractEnvReferences(config);
|
|
31
|
+
for (const key of referencedKeys) {
|
|
32
|
+
if (process.env[key] !== undefined) {
|
|
33
|
+
env[key] = process.env[key];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return env;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Scan all template strings in a PipelineConfig for {{env.X}} patterns
|
|
41
|
+
* and return the set of referenced env var names.
|
|
42
|
+
*/
|
|
43
|
+
function extractEnvReferences(config) {
|
|
44
|
+
const keys = new Set();
|
|
45
|
+
const ENV_REGEX = /\{\{env\.([^}]+)\}\}/g;
|
|
46
|
+
function scanValue(value) {
|
|
47
|
+
if (typeof value === "string") {
|
|
48
|
+
for (const match of value.matchAll(ENV_REGEX)) {
|
|
49
|
+
keys.add(match[1]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else if (Array.isArray(value)) {
|
|
53
|
+
for (const item of value) {
|
|
54
|
+
scanValue(item);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else if (value !== null && typeof value === "object") {
|
|
58
|
+
for (const v of Object.values(value)) {
|
|
59
|
+
scanValue(v);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Scan top-level actions
|
|
64
|
+
scanValue(config.actions);
|
|
65
|
+
// Scan per-tab actions
|
|
66
|
+
if (config.tabs) {
|
|
67
|
+
for (const tab of Object.values(config.tabs)) {
|
|
68
|
+
scanValue(tab.actions);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return keys;
|
|
72
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ExecAction, ExecutionContext } from "./types.js";
|
|
2
|
+
export interface ExecResult {
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
exitCode: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Execute a shell command and capture its output.
|
|
9
|
+
*
|
|
10
|
+
* Uses execFile('/bin/sh', ['-c', command]) for shell features (pipes, env vars)
|
|
11
|
+
* while staying consistent with the codebase's execFile pattern.
|
|
12
|
+
*/
|
|
13
|
+
export declare function executeCommand(command: string, options?: {
|
|
14
|
+
timeout?: number;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
env?: Record<string, string>;
|
|
17
|
+
}): Promise<ExecResult>;
|
|
18
|
+
/**
|
|
19
|
+
* Execute an exec action: resolve templates in the command, run it,
|
|
20
|
+
* optionally extract a value from JSON output, and handle errors.
|
|
21
|
+
*/
|
|
22
|
+
export declare function executeExecAction(action: ExecAction, context: ExecutionContext, options?: {
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}): Promise<string | null>;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { extractValue } from "./extractor.js";
|
|
3
|
+
import { shellEscape } from "./shell-escape.js";
|
|
4
|
+
import { resolveTemplateEscaped } from "./template.js";
|
|
5
|
+
/**
|
|
6
|
+
* Execute a shell command and capture its output.
|
|
7
|
+
*
|
|
8
|
+
* Uses execFile('/bin/sh', ['-c', command]) for shell features (pipes, env vars)
|
|
9
|
+
* while staying consistent with the codebase's execFile pattern.
|
|
10
|
+
*/
|
|
11
|
+
export async function executeCommand(command, options = {}) {
|
|
12
|
+
const { timeout = 30_000, signal, env } = options;
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const childEnv = env ?? {};
|
|
15
|
+
const child = execFile("/bin/sh", ["-c", command], {
|
|
16
|
+
timeout,
|
|
17
|
+
signal,
|
|
18
|
+
env: childEnv,
|
|
19
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB
|
|
20
|
+
}, (error, stdout, stderr) => {
|
|
21
|
+
if (error) {
|
|
22
|
+
// Cast to access killed and code properties from ExecException
|
|
23
|
+
const execError = error;
|
|
24
|
+
// Check if it was killed by timeout or signal
|
|
25
|
+
if (execError.killed || error.message?.includes("TIMEOUT")) {
|
|
26
|
+
reject(new Error(`Command timed out after ${timeout}ms`));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// If signal was aborted
|
|
30
|
+
if (signal?.aborted) {
|
|
31
|
+
reject(new Error("Command aborted"));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// Non-zero exit code — resolve with the exit code and captured output
|
|
35
|
+
resolve({
|
|
36
|
+
stdout: String(stdout ?? ""),
|
|
37
|
+
stderr: String(stderr ?? ""),
|
|
38
|
+
exitCode: typeof execError.code === "number" ? execError.code : 1,
|
|
39
|
+
});
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
resolve({
|
|
43
|
+
stdout: String(stdout),
|
|
44
|
+
stderr: String(stderr),
|
|
45
|
+
exitCode: 0,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
// Handle abort signal — kill the entire process group so grandchildren
|
|
49
|
+
// (e.g. headless Claude) are also terminated, not just the /bin/sh wrapper.
|
|
50
|
+
if (signal) {
|
|
51
|
+
signal.addEventListener("abort", () => {
|
|
52
|
+
try {
|
|
53
|
+
if (child.pid) {
|
|
54
|
+
process.kill(-child.pid, "SIGTERM");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
child.kill();
|
|
59
|
+
}
|
|
60
|
+
}, { once: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the onError action for a given exit code.
|
|
66
|
+
* Checks the specific exit code first, then falls back to "default".
|
|
67
|
+
*/
|
|
68
|
+
function resolveErrorAction(onError, exitCode) {
|
|
69
|
+
if (!onError)
|
|
70
|
+
return undefined;
|
|
71
|
+
const codeKey = String(exitCode);
|
|
72
|
+
if (codeKey in onError) {
|
|
73
|
+
return onError[codeKey];
|
|
74
|
+
}
|
|
75
|
+
if ("default" in onError) {
|
|
76
|
+
return onError.default;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Apply the resolved error action, returning a fallback value or throwing.
|
|
82
|
+
*/
|
|
83
|
+
function applyErrorAction(action, exitCode, stderr) {
|
|
84
|
+
if (action === undefined) {
|
|
85
|
+
throw new Error(`Command failed with exit code ${exitCode}${stderr ? `: ${stderr.trim()}` : ""}`);
|
|
86
|
+
}
|
|
87
|
+
if (action === "skip") {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (typeof action === "object" && "write" in action) {
|
|
91
|
+
return action.write;
|
|
92
|
+
}
|
|
93
|
+
// Unknown action — treat as skip
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Execute an exec action: resolve templates in the command, run it,
|
|
98
|
+
* optionally extract a value from JSON output, and handle errors.
|
|
99
|
+
*/
|
|
100
|
+
export async function executeExecAction(action, context, options = {}) {
|
|
101
|
+
const resolvedCommand = resolveTemplateEscaped(action.command, context, shellEscape);
|
|
102
|
+
let result;
|
|
103
|
+
try {
|
|
104
|
+
result = await executeCommand(resolvedCommand, {
|
|
105
|
+
timeout: action.timeout,
|
|
106
|
+
signal: options.signal,
|
|
107
|
+
env: context.env,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
// Timeout or abort errors
|
|
112
|
+
const errorAction = resolveErrorAction(action.onError, 1);
|
|
113
|
+
return applyErrorAction(errorAction, 1, error instanceof Error ? error.message : String(error));
|
|
114
|
+
}
|
|
115
|
+
// Non-zero exit code
|
|
116
|
+
if (result.exitCode !== 0) {
|
|
117
|
+
const errorAction = resolveErrorAction(action.onError, result.exitCode);
|
|
118
|
+
return applyErrorAction(errorAction, result.exitCode, result.stderr);
|
|
119
|
+
}
|
|
120
|
+
// Success — extract or return raw stdout
|
|
121
|
+
if (action.extract) {
|
|
122
|
+
let parsed;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(result.stdout);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw new Error(`Exec action "${action.id}": output is not valid JSON for extraction`);
|
|
128
|
+
}
|
|
129
|
+
const value = extractValue(parsed, action.extract);
|
|
130
|
+
return value !== "" ? value : null;
|
|
131
|
+
}
|
|
132
|
+
const trimmed = result.stdout.trim();
|
|
133
|
+
return trimmed !== "" ? trimmed : null;
|
|
134
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract a value from data using a JSONPath expression.
|
|
3
|
+
*
|
|
4
|
+
* - Applies the JSONPath expression to the input data
|
|
5
|
+
* - Arrays: takes the first element
|
|
6
|
+
* - Objects: JSON.stringify
|
|
7
|
+
* - Coerces the final result to string
|
|
8
|
+
* - Returns empty string if no match
|
|
9
|
+
*/
|
|
10
|
+
export declare function extractValue(data: unknown, expression: string): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { JSONPath } from "jsonpath-plus";
|
|
2
|
+
/**
|
|
3
|
+
* Extract a value from data using a JSONPath expression.
|
|
4
|
+
*
|
|
5
|
+
* - Applies the JSONPath expression to the input data
|
|
6
|
+
* - Arrays: takes the first element
|
|
7
|
+
* - Objects: JSON.stringify
|
|
8
|
+
* - Coerces the final result to string
|
|
9
|
+
* - Returns empty string if no match
|
|
10
|
+
*/
|
|
11
|
+
export function extractValue(data, expression) {
|
|
12
|
+
let result;
|
|
13
|
+
try {
|
|
14
|
+
result = JSONPath({ path: expression, json: data, eval: false });
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
// JSONPath always returns an array of matches
|
|
20
|
+
if (Array.isArray(result)) {
|
|
21
|
+
if (result.length === 0) {
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
result = result[0];
|
|
25
|
+
}
|
|
26
|
+
if (result === undefined || result === null) {
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
if (typeof result === "object") {
|
|
30
|
+
return JSON.stringify(result);
|
|
31
|
+
}
|
|
32
|
+
return String(result);
|
|
33
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { RateLimiter } from "./rate-limiter.js";
|
|
2
|
+
import type { OnErrorConfig } from "./types.js";
|
|
3
|
+
/** Options for httpRequest */
|
|
4
|
+
export interface HttpRequestOptions {
|
|
5
|
+
method: string;
|
|
6
|
+
url: string;
|
|
7
|
+
headers?: Record<string, string>;
|
|
8
|
+
body?: unknown;
|
|
9
|
+
retryAttempts?: number;
|
|
10
|
+
retryBackoff?: string;
|
|
11
|
+
onError?: OnErrorConfig;
|
|
12
|
+
rateLimiter?: RateLimiter;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
}
|
|
15
|
+
/** Successful HTTP response */
|
|
16
|
+
export interface HttpResponse {
|
|
17
|
+
status: number;
|
|
18
|
+
data: unknown;
|
|
19
|
+
}
|
|
20
|
+
/** Thrown when onError config specifies "stop_provider" */
|
|
21
|
+
export declare class StopProviderError extends Error {
|
|
22
|
+
constructor(message?: string);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Make an HTTP request with retry, rate limiting, and structured error handling.
|
|
26
|
+
*
|
|
27
|
+
* - Acquires a rate limiter token before each request attempt
|
|
28
|
+
* - Retries on 429/5xx with exponential backoff
|
|
29
|
+
* - Applies onError config for non-retryable errors or exhausted retries
|
|
30
|
+
* - Respects AbortSignal for cancellation
|
|
31
|
+
*/
|
|
32
|
+
export declare function httpRequest(options: HttpRequestOptions): Promise<HttpResponse | null>;
|