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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +258 -0
  3. package/dist/adapters/adapter.d.ts +1 -0
  4. package/dist/adapters/adapter.js +1 -0
  5. package/dist/adapters/sheets/sheets-adapter.d.ts +66 -0
  6. package/dist/adapters/sheets/sheets-adapter.js +531 -0
  7. package/dist/cli/config.d.ts +2 -0
  8. package/dist/cli/config.js +397 -0
  9. package/dist/cli/env.d.ts +3 -0
  10. package/dist/cli/env.js +103 -0
  11. package/dist/cli/format.d.ts +5 -0
  12. package/dist/cli/format.js +6 -0
  13. package/dist/cli/index.d.ts +2 -0
  14. package/dist/cli/index.js +39 -0
  15. package/dist/cli/init.d.ts +10 -0
  16. package/dist/cli/init.js +72 -0
  17. package/dist/cli/run.d.ts +2 -0
  18. package/dist/cli/run.js +212 -0
  19. package/dist/cli/runs.d.ts +2 -0
  20. package/dist/cli/runs.js +108 -0
  21. package/dist/cli/status.d.ts +2 -0
  22. package/dist/cli/status.js +108 -0
  23. package/dist/cli/sync.d.ts +2 -0
  24. package/dist/cli/sync.js +84 -0
  25. package/dist/cli/watch.d.ts +2 -0
  26. package/dist/cli/watch.js +348 -0
  27. package/dist/core/condition.d.ts +25 -0
  28. package/dist/core/condition.js +66 -0
  29. package/dist/core/defaults.d.ts +3 -0
  30. package/dist/core/defaults.js +7 -0
  31. package/dist/core/engine.d.ts +50 -0
  32. package/dist/core/engine.js +234 -0
  33. package/dist/core/env.d.ts +13 -0
  34. package/dist/core/env.js +72 -0
  35. package/dist/core/exec.d.ts +24 -0
  36. package/dist/core/exec.js +134 -0
  37. package/dist/core/extractor.d.ts +10 -0
  38. package/dist/core/extractor.js +33 -0
  39. package/dist/core/http-client.d.ts +32 -0
  40. package/dist/core/http-client.js +161 -0
  41. package/dist/core/rate-limiter.d.ts +25 -0
  42. package/dist/core/rate-limiter.js +64 -0
  43. package/dist/core/reconcile.d.ts +24 -0
  44. package/dist/core/reconcile.js +192 -0
  45. package/dist/core/run-format.d.ts +39 -0
  46. package/dist/core/run-format.js +201 -0
  47. package/dist/core/run-state.d.ts +64 -0
  48. package/dist/core/run-state.js +141 -0
  49. package/dist/core/run-tracker.d.ts +15 -0
  50. package/dist/core/run-tracker.js +57 -0
  51. package/dist/core/safe-compare.d.ts +8 -0
  52. package/dist/core/safe-compare.js +19 -0
  53. package/dist/core/shell-escape.d.ts +7 -0
  54. package/dist/core/shell-escape.js +9 -0
  55. package/dist/core/tab-resolver.d.ts +17 -0
  56. package/dist/core/tab-resolver.js +44 -0
  57. package/dist/core/template.d.ts +32 -0
  58. package/dist/core/template.js +82 -0
  59. package/dist/core/types.d.ts +105 -0
  60. package/dist/core/types.js +2 -0
  61. package/dist/core/url-guard.d.ts +21 -0
  62. package/dist/core/url-guard.js +184 -0
  63. package/dist/core/validator.d.ts +11 -0
  64. package/dist/core/validator.js +261 -0
  65. package/dist/core/waterfall.d.ts +26 -0
  66. package/dist/core/waterfall.js +55 -0
  67. package/dist/index.d.ts +15 -0
  68. package/dist/index.js +16 -0
  69. package/dist/mcp/server.d.ts +1 -0
  70. package/dist/mcp/server.js +943 -0
  71. package/package.json +67 -0
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Format milliseconds as a human-readable duration.
3
+ * Examples: "12s", "1m30s", "2h5m"
4
+ */
5
+ export function formatDuration(ms) {
6
+ const totalSeconds = Math.floor(ms / 1000);
7
+ if (totalSeconds < 1) {
8
+ return "<1s";
9
+ }
10
+ const hours = Math.floor(totalSeconds / 3600);
11
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
12
+ const seconds = totalSeconds % 60;
13
+ if (hours > 0) {
14
+ return minutes > 0 ? `${hours}h${minutes}m` : `${hours}h`;
15
+ }
16
+ if (minutes > 0) {
17
+ return seconds > 0 ? `${minutes}m${seconds}s` : `${minutes}m`;
18
+ }
19
+ return `${seconds}s`;
20
+ }
21
+ /**
22
+ * Format an ISO date as a relative "age" string.
23
+ * Examples: "just now", "5m ago", "2h ago", "3d ago", or "running" if status is running.
24
+ */
25
+ export function formatAge(isoDate, status) {
26
+ if (status === "running") {
27
+ return "running";
28
+ }
29
+ const then = new Date(isoDate).getTime();
30
+ const now = Date.now();
31
+ const diffMs = now - then;
32
+ const diffSeconds = Math.floor(diffMs / 1000);
33
+ if (diffSeconds < 60) {
34
+ return "just now";
35
+ }
36
+ const diffMinutes = Math.floor(diffSeconds / 60);
37
+ if (diffMinutes < 60) {
38
+ return `${diffMinutes}m ago`;
39
+ }
40
+ const diffHours = Math.floor(diffMinutes / 60);
41
+ if (diffHours < 24) {
42
+ return `${diffHours}h ago`;
43
+ }
44
+ const diffDays = Math.floor(diffHours / 24);
45
+ return `${diffDays}d ago`;
46
+ }
47
+ /** Get status icon for a run */
48
+ function statusIcon(run) {
49
+ switch (run.status) {
50
+ case "completed":
51
+ return run.errors.length === 0 ? "\u2713" : "\u2717";
52
+ case "failed":
53
+ return "\u2717";
54
+ case "running":
55
+ return "\u231b";
56
+ case "aborted":
57
+ return "\u2298";
58
+ default:
59
+ return "?";
60
+ }
61
+ }
62
+ /** Get display name for a sheet */
63
+ function sheetDisplayName(run) {
64
+ if (run.sheetName) {
65
+ return run.sheetName;
66
+ }
67
+ return run.sheetId.length > 12 ? run.sheetId.slice(0, 12) : run.sheetId;
68
+ }
69
+ /**
70
+ * Format a list of runs as a compact table.
71
+ *
72
+ * ```
73
+ * STATUS RUN SHEET ROWS UPDATES ERRORS DURATION AGE
74
+ * ✓ a1b2c3 EliteCart 30/30 28 0 12s 5m ago
75
+ * ```
76
+ */
77
+ export function formatRunList(runs) {
78
+ if (runs.length === 0) {
79
+ return "No runs found.";
80
+ }
81
+ const rows = [];
82
+ rows.push([
83
+ "STATUS",
84
+ "RUN",
85
+ "SHEET",
86
+ "ROWS",
87
+ "UPDATES",
88
+ "ERRORS",
89
+ "DURATION",
90
+ "AGE",
91
+ ]);
92
+ for (const run of runs) {
93
+ const icon = statusIcon(run);
94
+ const totalUpdates = run.actionSummaries.reduce((sum, s) => sum + s.success, 0);
95
+ const totalErrors = run.errors.length;
96
+ const duration = run.durationMs !== undefined ? formatDuration(run.durationMs) : "-";
97
+ const age = formatAge(run.startedAt, run.status);
98
+ const rowCount = run.totalRows > 0
99
+ ? `${run.processedRows}/${run.totalRows}`
100
+ : `${run.processedRows}`;
101
+ rows.push([
102
+ icon,
103
+ run.runId,
104
+ sheetDisplayName(run),
105
+ rowCount,
106
+ String(totalUpdates),
107
+ String(totalErrors),
108
+ duration,
109
+ age,
110
+ ]);
111
+ }
112
+ // Calculate column widths
113
+ const colWidths = rows[0].map((_, colIndex) => Math.max(...rows.map((row) => row[colIndex].length)));
114
+ // Format each row with padding
115
+ return rows
116
+ .map((row) => row.map((cell, i) => cell.padEnd(colWidths[i])).join(" "))
117
+ .join("\n");
118
+ }
119
+ /** Action status icon: ✓ (0 errors), ✗ (has errors), ⚠ (has skips but no errors) */
120
+ function actionStatusIcon(action) {
121
+ if (action.errors > 0)
122
+ return "\u2717";
123
+ if (action.skipped > 0)
124
+ return "\u26a0";
125
+ return "\u2713";
126
+ }
127
+ /**
128
+ * Format a detailed view of a single run.
129
+ *
130
+ * ```
131
+ * ✗ Run d4e5f6 · LeadList
132
+ * Sheet: 1xABC...def · Started: 2h ago · Duration: 45s
133
+ *
134
+ * ACTIONS
135
+ * extract_domain ✓ 150/150
136
+ * enrich_company ✗ 147/150 (3 errors)
137
+ * find_email ⚠ 120/147 (27 skipped)
138
+ *
139
+ * ERRORS (3)
140
+ * Row 45 enrich_company 429 Too Many Requests (retries exhausted)
141
+ * Row 89 enrich_company timeout after 30s
142
+ * Row 102 enrich_company 404 → wrote "not_found"
143
+ * ```
144
+ */
145
+ export function formatRunDetail(run, errorsOnly = false) {
146
+ const lines = [];
147
+ if (!errorsOnly) {
148
+ // Header
149
+ const icon = statusIcon(run);
150
+ const name = sheetDisplayName(run);
151
+ lines.push(`${icon} Run ${run.runId} \u00b7 ${name}`);
152
+ const sheetLabel = run.sheetId.length > 12
153
+ ? `${run.sheetId.slice(0, 6)}...${run.sheetId.slice(-3)}`
154
+ : run.sheetId;
155
+ const age = formatAge(run.startedAt, run.status);
156
+ const duration = run.durationMs !== undefined ? formatDuration(run.durationMs) : "-";
157
+ lines.push(` Sheet: ${sheetLabel} \u00b7 Started: ${age} \u00b7 Duration: ${duration}`);
158
+ if (run.dryRun) {
159
+ lines.push(" Mode: DRY RUN");
160
+ }
161
+ // Actions
162
+ if (run.actionSummaries.length > 0) {
163
+ lines.push("");
164
+ lines.push("ACTIONS");
165
+ // Calculate widths for alignment
166
+ const actionIdWidth = Math.max(...run.actionSummaries.map((s) => s.actionId.length));
167
+ for (const action of run.actionSummaries) {
168
+ const total = action.success + action.skipped + action.errors;
169
+ const icon = actionStatusIcon(action);
170
+ const details = [];
171
+ if (action.errors > 0) {
172
+ details.push(`${action.errors} errors`);
173
+ }
174
+ if (action.skipped > 0) {
175
+ details.push(`${action.skipped} skipped`);
176
+ }
177
+ const suffix = details.length > 0 ? ` (${details.join(", ")})` : "";
178
+ lines.push(` ${action.actionId.padEnd(actionIdWidth)} ${icon} ${action.success}/${total}${suffix}`);
179
+ }
180
+ }
181
+ }
182
+ // Errors section
183
+ if (run.errors.length > 0) {
184
+ if (!errorsOnly) {
185
+ lines.push("");
186
+ }
187
+ lines.push(`ERRORS (${run.errors.length})`);
188
+ // Calculate widths for alignment
189
+ const rowWidth = Math.max(...run.errors.map((e) => `Row ${e.rowIndex}`.length));
190
+ const actionWidth = Math.max(...run.errors.map((e) => e.actionId.length));
191
+ for (const error of run.errors) {
192
+ const rowLabel = `Row ${error.rowIndex}`.padEnd(rowWidth);
193
+ const actionLabel = error.actionId.padEnd(actionWidth);
194
+ lines.push(` ${rowLabel} ${actionLabel} ${error.error}`);
195
+ }
196
+ }
197
+ else if (errorsOnly) {
198
+ lines.push("No errors.");
199
+ }
200
+ return lines.join("\n");
201
+ }
@@ -0,0 +1,64 @@
1
+ import type { PipelineConfig } from "./types.js";
2
+ export interface ActionSummary {
3
+ actionId: string;
4
+ type: string;
5
+ target: string;
6
+ success: number;
7
+ skipped: number;
8
+ errors: number;
9
+ }
10
+ export interface RunError {
11
+ rowIndex: number;
12
+ actionId: string;
13
+ error: string;
14
+ }
15
+ export interface RunState {
16
+ runId: string;
17
+ sheetId: string;
18
+ sheetName?: string;
19
+ status: "running" | "completed" | "failed" | "aborted";
20
+ startedAt: string;
21
+ completedAt?: string;
22
+ durationMs?: number;
23
+ dryRun: boolean;
24
+ totalRows: number;
25
+ processedRows: number;
26
+ actionSummaries: ActionSummary[];
27
+ errors: RunError[];
28
+ settings: {
29
+ range?: string;
30
+ actionFilter?: string;
31
+ rateLimit: number;
32
+ retryAttempts: number;
33
+ };
34
+ }
35
+ /**
36
+ * Override the runs directory (for testing).
37
+ * Pass undefined to reset to default.
38
+ */
39
+ export declare function setRunsDir(dir: string | undefined): void;
40
+ /** Get the runs directory path, creating it if needed */
41
+ export declare function getRunsDir(): Promise<string>;
42
+ /** Generate a short run ID (8 chars, random hex) */
43
+ export declare function generateRunId(): string;
44
+ /** Write run state to disk */
45
+ export declare function writeRunState(state: RunState): Promise<void>;
46
+ /** Read a specific run state */
47
+ export declare function readRunState(runId: string): Promise<RunState | null>;
48
+ /** List all runs, sorted by startedAt descending (most recent first) */
49
+ export declare function listRuns(options?: {
50
+ sheetId?: string;
51
+ limit?: number;
52
+ }): Promise<RunState[]>;
53
+ /** Delete old runs, keeping the most recent N. Returns number deleted. */
54
+ export declare function pruneRuns(keep: number): Promise<number>;
55
+ /** Create a fresh RunState for a new pipeline run */
56
+ export declare function createRunState(options: {
57
+ sheetId: string;
58
+ sheetName?: string;
59
+ config: PipelineConfig;
60
+ totalRows: number;
61
+ dryRun: boolean;
62
+ range?: string;
63
+ actionFilter?: string;
64
+ }): RunState;
@@ -0,0 +1,141 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ /** Validate that a runId is a legitimate 8-char hex string (prevents path traversal) */
6
+ function validateRunId(runId) {
7
+ if (!/^[a-f0-9]{8}$/.test(runId)) {
8
+ throw new Error(`Invalid run ID "${runId}". Expected 8-character hex string.`);
9
+ }
10
+ }
11
+ /** Default runs directory under ~/.rowbound/runs */
12
+ let overrideRunsDir;
13
+ /**
14
+ * Override the runs directory (for testing).
15
+ * Pass undefined to reset to default.
16
+ */
17
+ export function setRunsDir(dir) {
18
+ overrideRunsDir = dir;
19
+ }
20
+ /** Get the runs directory path, creating it if needed */
21
+ export async function getRunsDir() {
22
+ const dir = overrideRunsDir ?? path.join(os.homedir(), ".rowbound", "runs");
23
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
24
+ return dir;
25
+ }
26
+ /** Generate a short run ID (8 chars, random hex) */
27
+ export function generateRunId() {
28
+ return crypto.randomBytes(4).toString("hex");
29
+ }
30
+ /** Write run state to disk */
31
+ export async function writeRunState(state) {
32
+ validateRunId(state.runId);
33
+ const filePath = path.join(await getRunsDir(), `${state.runId}.json`);
34
+ await fs.writeFile(filePath, JSON.stringify(state, null, 2), {
35
+ mode: 0o600,
36
+ });
37
+ }
38
+ /** Read a specific run state */
39
+ export async function readRunState(runId) {
40
+ validateRunId(runId);
41
+ const filePath = path.join(await getRunsDir(), `${runId}.json`);
42
+ try {
43
+ const data = await fs.readFile(filePath, "utf-8");
44
+ return JSON.parse(data);
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /** List all runs, sorted by startedAt descending (most recent first) */
51
+ export async function listRuns(options) {
52
+ const dir = await getRunsDir();
53
+ const limit = options?.limit ?? 20;
54
+ let files;
55
+ try {
56
+ files = (await fs.readdir(dir)).filter((f) => f.endsWith(".json"));
57
+ }
58
+ catch {
59
+ return [];
60
+ }
61
+ const runs = [];
62
+ for (const file of files) {
63
+ try {
64
+ const data = await fs.readFile(path.join(dir, file), "utf-8");
65
+ const state = JSON.parse(data);
66
+ if (options?.sheetId && state.sheetId !== options.sheetId) {
67
+ continue;
68
+ }
69
+ runs.push(state);
70
+ }
71
+ catch {
72
+ // Skip corrupted files
73
+ }
74
+ }
75
+ runs.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
76
+ return runs.slice(0, limit);
77
+ }
78
+ /** Delete old runs, keeping the most recent N. Returns number deleted. */
79
+ export async function pruneRuns(keep) {
80
+ const dir = await getRunsDir();
81
+ let files;
82
+ try {
83
+ files = (await fs.readdir(dir)).filter((f) => f.endsWith(".json"));
84
+ }
85
+ catch {
86
+ return 0;
87
+ }
88
+ // Read and sort all runs by startedAt descending
89
+ const runs = [];
90
+ for (const file of files) {
91
+ try {
92
+ const data = await fs.readFile(path.join(dir, file), "utf-8");
93
+ const state = JSON.parse(data);
94
+ runs.push({ file, startedAt: state.startedAt });
95
+ }
96
+ catch {
97
+ // Corrupted files get deleted
98
+ runs.push({ file, startedAt: "" });
99
+ }
100
+ }
101
+ runs.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
102
+ let deleted = 0;
103
+ for (let i = keep; i < runs.length; i++) {
104
+ try {
105
+ await fs.unlink(path.join(dir, runs[i].file));
106
+ deleted++;
107
+ }
108
+ catch {
109
+ // Ignore deletion errors
110
+ }
111
+ }
112
+ return deleted;
113
+ }
114
+ /** Create a fresh RunState for a new pipeline run */
115
+ export function createRunState(options) {
116
+ return {
117
+ runId: generateRunId(),
118
+ sheetId: options.sheetId,
119
+ sheetName: options.sheetName,
120
+ status: "running",
121
+ startedAt: new Date().toISOString(),
122
+ dryRun: options.dryRun,
123
+ totalRows: options.totalRows,
124
+ processedRows: 0,
125
+ actionSummaries: options.config.actions.map((action) => ({
126
+ actionId: action.id,
127
+ type: action.type,
128
+ target: action.target,
129
+ success: 0,
130
+ skipped: 0,
131
+ errors: 0,
132
+ })),
133
+ errors: [],
134
+ settings: {
135
+ range: options.range,
136
+ actionFilter: options.actionFilter,
137
+ rateLimit: options.config.settings.rateLimit,
138
+ retryAttempts: options.config.settings.retryAttempts,
139
+ },
140
+ };
141
+ }
@@ -0,0 +1,15 @@
1
+ import type { RunPipelineOptions } from "./engine.js";
2
+ import type { RunState } from "./run-state.js";
3
+ /**
4
+ * Create callback hooks that track run state and write to disk.
5
+ *
6
+ * The returned callbacks should be composed with any user-provided callbacks,
7
+ * not replace them.
8
+ */
9
+ export declare function createRunTracker(state: RunState): {
10
+ onRowStart: NonNullable<RunPipelineOptions["onRowStart"]>;
11
+ onActionComplete: NonNullable<RunPipelineOptions["onActionComplete"]>;
12
+ onError: NonNullable<RunPipelineOptions["onError"]>;
13
+ onRowComplete: NonNullable<RunPipelineOptions["onRowComplete"]>;
14
+ finalize: (aborted: boolean) => Promise<void>;
15
+ };
@@ -0,0 +1,57 @@
1
+ import { pruneRuns, writeRunState } from "./run-state.js";
2
+ /**
3
+ * Create callback hooks that track run state and write to disk.
4
+ *
5
+ * The returned callbacks should be composed with any user-provided callbacks,
6
+ * not replace them.
7
+ */
8
+ export function createRunTracker(state) {
9
+ const startTime = Date.now();
10
+ return {
11
+ onRowStart: (_rowIndex, _row) => {
12
+ // No-op — reserved for future row-level tracking
13
+ },
14
+ onActionComplete: (_rowIndex, actionId, value) => {
15
+ const action = state.actionSummaries.find((s) => s.actionId === actionId);
16
+ if (!action)
17
+ return;
18
+ if (value !== null) {
19
+ action.success++;
20
+ }
21
+ else {
22
+ action.skipped++;
23
+ }
24
+ },
25
+ onError: (rowIndex, actionId, error) => {
26
+ const action = state.actionSummaries.find((s) => s.actionId === actionId);
27
+ if (action) {
28
+ action.errors++;
29
+ }
30
+ state.errors.push({
31
+ rowIndex: rowIndex + 2, // Convert 0-indexed data row to sheet row number
32
+ actionId,
33
+ error: error.message,
34
+ });
35
+ },
36
+ onRowComplete: (_rowIndex, _updates) => {
37
+ state.processedRows++;
38
+ // Fire-and-forget: checkpoint save after each row (best-effort)
39
+ writeRunState(state).catch(() => { });
40
+ },
41
+ finalize: async (aborted) => {
42
+ if (aborted) {
43
+ state.status = "aborted";
44
+ }
45
+ else if (state.errors.length > 0 && state.processedRows === 0) {
46
+ state.status = "failed";
47
+ }
48
+ else {
49
+ state.status = "completed";
50
+ }
51
+ state.completedAt = new Date().toISOString();
52
+ state.durationMs = Date.now() - startTime;
53
+ await writeRunState(state);
54
+ await pruneRuns(50);
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Constant-time string comparison to prevent timing attacks on secret tokens.
3
+ *
4
+ * Uses crypto.timingSafeEqual under the hood. When lengths differ, we still
5
+ * compare against a same-length dummy to avoid leaking length information
6
+ * through early return timing.
7
+ */
8
+ export declare function safeCompare(a: string, b: string): boolean;
@@ -0,0 +1,19 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ /**
3
+ * Constant-time string comparison to prevent timing attacks on secret tokens.
4
+ *
5
+ * Uses crypto.timingSafeEqual under the hood. When lengths differ, we still
6
+ * compare against a same-length dummy to avoid leaking length information
7
+ * through early return timing.
8
+ */
9
+ export function safeCompare(a, b) {
10
+ const bufA = Buffer.from(a, "utf-8");
11
+ const bufB = Buffer.from(b, "utf-8");
12
+ if (bufA.length !== bufB.length) {
13
+ // Compare bufA against itself to burn the same amount of time,
14
+ // then return false. This avoids leaking length information.
15
+ timingSafeEqual(bufA, bufA);
16
+ return false;
17
+ }
18
+ return timingSafeEqual(bufA, bufB);
19
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Escape a string for safe use in shell commands.
3
+ *
4
+ * Wraps the value in single quotes and escapes any embedded single quotes.
5
+ * This prevents shell injection via metacharacters like $(), backticks, ;, |, etc.
6
+ */
7
+ export declare function shellEscape(value: string): string;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Escape a string for safe use in shell commands.
3
+ *
4
+ * Wraps the value in single quotes and escapes any embedded single quotes.
5
+ * This prevents shell injection via metacharacters like $(), backticks, ;, |, etc.
6
+ */
7
+ export function shellEscape(value) {
8
+ return `'${value.replace(/'/g, "'\\''")}'`;
9
+ }
@@ -0,0 +1,17 @@
1
+ import type { PipelineConfig, TabConfig } from "./types.js";
2
+ /**
3
+ * Resolve a tab by name in a v2 config.
4
+ * Returns the GID key and TabConfig, or null if not found.
5
+ */
6
+ export declare function resolveTabGid(config: PipelineConfig, tabName: string): {
7
+ gid: string;
8
+ tab: TabConfig;
9
+ } | null;
10
+ /**
11
+ * Get the tab config for a given tab name, handling single-tab defaults.
12
+ * For v1 configs, returns a synthetic TabConfig from top-level fields.
13
+ */
14
+ export declare function getTabConfig(config: PipelineConfig, tabName?: string): {
15
+ gid: string;
16
+ tab: TabConfig;
17
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Resolve a tab by name in a v2 config.
3
+ * Returns the GID key and TabConfig, or null if not found.
4
+ */
5
+ export function resolveTabGid(config, tabName) {
6
+ if (!config.tabs)
7
+ return null;
8
+ for (const [gid, tab] of Object.entries(config.tabs)) {
9
+ if (tab.name === tabName) {
10
+ return { gid, tab };
11
+ }
12
+ }
13
+ return null;
14
+ }
15
+ /**
16
+ * Get the tab config for a given tab name, handling single-tab defaults.
17
+ * For v1 configs, returns a synthetic TabConfig from top-level fields.
18
+ */
19
+ export function getTabConfig(config, tabName) {
20
+ if (config.tabs) {
21
+ const entries = Object.entries(config.tabs);
22
+ if (tabName) {
23
+ const resolved = resolveTabGid(config, tabName);
24
+ if (!resolved) {
25
+ throw new Error(`Tab "${tabName}" not found. Available: ${entries.map(([, t]) => t.name).join(", ")}`);
26
+ }
27
+ return resolved;
28
+ }
29
+ if (entries.length === 1) {
30
+ const [gid, tab] = entries[0];
31
+ return { gid, tab };
32
+ }
33
+ throw new Error(`Multiple tabs configured. Specify tab. Available: ${entries.map(([, t]) => t.name).join(", ")}`);
34
+ }
35
+ // v1 fallback
36
+ return {
37
+ gid: "0",
38
+ tab: {
39
+ name: tabName || "Sheet1",
40
+ columns: config.columns ?? {},
41
+ actions: config.actions ?? [],
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,32 @@
1
+ import type { ExecutionContext } from "./types.js";
2
+ /**
3
+ * Callback invoked when a template variable resolves to undefined.
4
+ * @param source - "row" or "env"
5
+ * @param key - the variable name that was missing
6
+ */
7
+ export type OnMissingCallback = (source: string, key: string) => void;
8
+ /**
9
+ * Resolve template strings like {{row.email}} and {{env.API_KEY}}.
10
+ * Missing variables resolve to empty string.
11
+ *
12
+ * When `onMissing` is provided, it is called for every variable that
13
+ * resolves to `undefined` in the given context.
14
+ */
15
+ export declare function resolveTemplate(template: string, context: ExecutionContext, onMissing?: OnMissingCallback): string;
16
+ /**
17
+ * Resolve template strings with an escape function applied to each resolved value.
18
+ *
19
+ * Used for shell contexts where row/env values must be sanitized before
20
+ * interpolation (e.g., shell-escaping to prevent command injection).
21
+ * The escape function is applied to each resolved placeholder value,
22
+ * NOT to static parts of the template.
23
+ */
24
+ export declare function resolveTemplateEscaped(template: string, context: ExecutionContext, escapeFn: (value: string) => string, onMissing?: OnMissingCallback): string;
25
+ /**
26
+ * Recursively resolve templates in an object/array/string.
27
+ * - Strings: resolve template placeholders
28
+ * - Arrays: resolve each element
29
+ * - Objects: resolve each value (keys are not resolved)
30
+ * - Other types: pass through unchanged
31
+ */
32
+ export declare function resolveObject(obj: unknown, context: ExecutionContext, onMissing?: OnMissingCallback, depth?: number): unknown;