rafaygen-cli 1.3.2 → 1.3.3

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/src/executor.js CHANGED
@@ -1,87 +1,737 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { exec } from "child_process";
4
- import { renderBox, renderDiffBox, printSuccess, printError } from "./ui.js";
4
+ import chalk from "chalk";
5
+ import inquirer from "inquirer";
6
+ import boxen from "boxen";
7
+ import {
8
+ renderBox,
9
+ renderDiffBox,
10
+ renderCodeBox,
11
+ printSuccess,
12
+ printError,
13
+ printWarning,
14
+ } from "./ui.js";
5
15
  import { getSessionState } from "./state.js";
6
16
 
7
- export async function executeAction(action) {
8
- const inquirer = (await import("inquirer")).default;
9
- const chalk = (await import("chalk")).default;
17
+ // ──────────────────────────────────────────────
18
+ // Sandbox Policy
19
+ // ──────────────────────────────────────────────
20
+
21
+ const SANDBOX_RULES = {
22
+ "read-only": {
23
+ allowed: new Set(["read"]),
24
+ label: "read-only",
25
+ },
26
+ "workspace-write": {
27
+ allowed: new Set(["read", "write", "patch", "delete", "mkdir"]),
28
+ label: "workspace-write",
29
+ },
30
+ "danger-full-access": {
31
+ allowed: new Set(["read", "write", "patch", "delete", "mkdir", "execute"]),
32
+ label: "danger-full-access",
33
+ },
34
+ };
35
+
36
+ /**
37
+ * Checks whether the current sandbox mode permits the given action type.
38
+ * For workspace-write mode, also enforces that file paths stay within cwd.
39
+ * Returns { allowed: boolean, reason?: string }
40
+ */
41
+ function checkSandbox(actionType, targetPath) {
10
42
  const state = getSessionState();
43
+ const mode = state.sandboxMode || "danger-full-access";
44
+ const rules = SANDBOX_RULES[mode];
45
+
46
+ if (!rules) {
47
+ return {
48
+ allowed: false,
49
+ reason: `Unknown sandbox mode "${mode}".`,
50
+ };
51
+ }
11
52
 
12
- if (action.type === "write") {
13
- // Sandbox Check
14
- if (state.sandboxMode === "read-only") {
15
- console.log(chalk.red("\n✖ Action blocked: Sandbox mode is set to 'read-only'. (Cannot write files)"));
16
- return;
53
+ if (!rules.allowed.has(actionType)) {
54
+ return {
55
+ allowed: false,
56
+ reason: `Sandbox mode "${rules.label}" does not allow "${actionType}" actions.`,
57
+ };
58
+ }
59
+
60
+ // workspace-write: enforce cwd boundary for mutating file operations
61
+ if (mode === "workspace-write" && targetPath) {
62
+ const resolvedTarget = path.resolve(state.cwd, targetPath);
63
+ const resolvedCwd = path.resolve(state.cwd);
64
+ if (!resolvedTarget.startsWith(resolvedCwd + path.sep) && resolvedTarget !== resolvedCwd) {
65
+ return {
66
+ allowed: false,
67
+ reason: `Path "${targetPath}" is outside the current workspace. Sandbox mode "${rules.label}" only permits operations within "${resolvedCwd}".`,
68
+ };
17
69
  }
70
+ }
71
+
72
+ return { allowed: true };
73
+ }
74
+
75
+ // ──────────────────────────────────────────────
76
+ // Approval Policy
77
+ // ──────────────────────────────────────────────
78
+
79
+ /**
80
+ * Determines whether the user must be prompted for approval.
81
+ * Returns { proceed: boolean, autoApproved: boolean, blocked: boolean }
82
+ *
83
+ * approvalMode values:
84
+ * suggest – always ask for confirmation
85
+ * auto-edit – auto-approve writes/patch/delete/mkdir, ask for execute
86
+ * full-auto – auto-approve everything
87
+ * never – block everything (no actions allowed)
88
+ */
89
+ function resolveApproval(actionType) {
90
+ const state = getSessionState();
91
+ const mode = state.approvalMode || "suggest";
92
+
93
+ switch (mode) {
94
+ case "never":
95
+ return { proceed: false, autoApproved: false, blocked: true };
18
96
 
19
- const filepath = path.resolve(state.cwd, action.file);
20
- let original = "";
21
- if (fs.existsSync(filepath)) {
22
- original = fs.readFileSync(filepath, "utf-8");
97
+ case "full-auto":
98
+ return { proceed: true, autoApproved: true, blocked: false };
99
+
100
+ case "auto-edit": {
101
+ const autoTypes = new Set(["write", "patch", "delete", "mkdir", "read"]);
102
+ if (autoTypes.has(actionType)) {
103
+ return { proceed: true, autoApproved: true, blocked: false };
104
+ }
105
+ // execute still needs confirmation
106
+ return { proceed: false, autoApproved: false, blocked: false };
23
107
  }
24
108
 
25
- // Show what will be written
26
- renderDiffBox(action.file, original, action.content);
27
-
28
- // Approval Check
29
- let confirm = true;
30
- if (state.approvalMode !== "full-auto") {
31
- const answers = await inquirer.prompt([
32
- {
33
- type: "confirm",
34
- name: "confirm",
35
- message: `Allow RafayGen to write to ${action.file}?`,
36
- default: true
37
- }
38
- ]);
39
- confirm = answers.confirm;
109
+ case "suggest":
110
+ default:
111
+ // read is always auto-approved even in suggest mode
112
+ if (actionType === "read") {
113
+ return { proceed: true, autoApproved: true, blocked: false };
114
+ }
115
+ return { proceed: false, autoApproved: false, blocked: false };
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Prompts the user for confirmation with a styled message.
121
+ * Returns true if user confirms.
122
+ */
123
+ async function askConfirmation(message) {
124
+ const { confirmed } = await inquirer.prompt([
125
+ {
126
+ type: "confirm",
127
+ name: "confirmed",
128
+ message,
129
+ default: true,
130
+ },
131
+ ]);
132
+ return confirmed;
133
+ }
134
+
135
+ // ──────────────────────────────────────────────
136
+ // Shared helpers
137
+ // ──────────────────────────────────────────────
138
+
139
+ function resolvePath(filePath) {
140
+ const state = getSessionState();
141
+ return path.resolve(state.cwd, filePath);
142
+ }
143
+
144
+ function detectLanguage(filePath) {
145
+ const ext = path.extname(filePath).toLowerCase();
146
+ const map = {
147
+ ".js": "javascript",
148
+ ".mjs": "javascript",
149
+ ".cjs": "javascript",
150
+ ".jsx": "javascript",
151
+ ".ts": "typescript",
152
+ ".tsx": "typescript",
153
+ ".py": "python",
154
+ ".rb": "ruby",
155
+ ".go": "go",
156
+ ".rs": "rust",
157
+ ".java": "java",
158
+ ".c": "c",
159
+ ".cpp": "cpp",
160
+ ".h": "cpp",
161
+ ".hpp": "cpp",
162
+ ".cs": "csharp",
163
+ ".php": "php",
164
+ ".html": "html",
165
+ ".htm": "html",
166
+ ".css": "css",
167
+ ".scss": "scss",
168
+ ".sass": "sass",
169
+ ".less": "less",
170
+ ".json": "json",
171
+ ".xml": "xml",
172
+ ".yml": "yaml",
173
+ ".yaml": "yaml",
174
+ ".md": "markdown",
175
+ ".sql": "sql",
176
+ ".sh": "bash",
177
+ ".bash": "bash",
178
+ ".zsh": "bash",
179
+ ".fish": "bash",
180
+ ".ps1": "powershell",
181
+ ".bat": "bat",
182
+ ".cmd": "bat",
183
+ ".dockerfile": "dockerfile",
184
+ ".toml": "toml",
185
+ ".ini": "ini",
186
+ ".cfg": "ini",
187
+ ".lua": "lua",
188
+ ".r": "r",
189
+ ".R": "r",
190
+ ".swift": "swift",
191
+ ".kt": "kotlin",
192
+ ".kts": "kotlin",
193
+ ".dart": "dart",
194
+ ".ex": "elixir",
195
+ ".exs": "elixir",
196
+ ".erl": "erlang",
197
+ ".hs": "haskell",
198
+ ".vue": "html",
199
+ ".svelte": "html",
200
+ };
201
+ return map[ext] || "plaintext";
202
+ }
203
+
204
+ function formatBytes(bytes) {
205
+ if (bytes < 1024) return `${bytes} B`;
206
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
207
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
208
+ }
209
+
210
+ // ──────────────────────────────────────────────
211
+ // Action Handlers
212
+ // ──────────────────────────────────────────────
213
+
214
+ /**
215
+ * type='write' — Write content to a file.
216
+ * Shows a diff box comparing old vs new content, asks for approval,
217
+ * then writes the file (creating parent directories as needed).
218
+ */
219
+ async function handleWrite(action) {
220
+ const filePath = resolvePath(action.file);
221
+ const relPath = action.file;
222
+
223
+ // Read existing content for diff (empty string if new file)
224
+ let original = "";
225
+ let isNew = true;
226
+ if (fs.existsSync(filePath)) {
227
+ original = fs.readFileSync(filePath, "utf-8");
228
+ isNew = false;
229
+ }
230
+
231
+ const content = action.content || "";
232
+
233
+ // Show diff
234
+ if (isNew) {
235
+ console.log(
236
+ chalk.cyan.bold("\n📄 New file: ") + chalk.white(relPath)
237
+ );
238
+ renderCodeBox(relPath, content, detectLanguage(relPath));
239
+ } else {
240
+ renderDiffBox(relPath, original, content);
241
+ }
242
+
243
+ // Approval
244
+ const approval = resolveApproval("write");
245
+ if (approval.blocked) {
246
+ printWarning(`Action blocked: approval mode is set to "never".`);
247
+ return { applied: false, reason: "blocked" };
248
+ }
249
+
250
+ let confirmed = approval.proceed;
251
+ if (!confirmed) {
252
+ confirmed = await askConfirmation(
253
+ `Allow writing to ${chalk.bold(relPath)}?`
254
+ );
255
+ } else if (approval.autoApproved) {
256
+ console.log(chalk.dim(` ⚡ Auto-approved (${getSessionState().approvalMode})`));
257
+ }
258
+
259
+ if (!confirmed) {
260
+ console.log(chalk.yellow(" ↩ Skipped write."));
261
+ return { applied: false, reason: "declined" };
262
+ }
263
+
264
+ // Write
265
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
266
+ fs.writeFileSync(filePath, content, "utf-8");
267
+ printSuccess(`Saved ${relPath} (${formatBytes(Buffer.byteLength(content, "utf-8"))})`);
268
+ return { applied: true };
269
+ }
270
+
271
+ /**
272
+ * type='execute' — Execute a shell command.
273
+ * Shows the command in a box, asks for approval, runs it.
274
+ */
275
+ async function handleExecute(action) {
276
+ const command = action.command;
277
+ const state = getSessionState();
278
+
279
+ // Display the command
280
+ renderBox(
281
+ " 🔧 Execute Command ",
282
+ chalk.yellowBright(command),
283
+ "magenta"
284
+ );
285
+
286
+ if (action.description) {
287
+ console.log(chalk.dim(` Description: ${action.description}`));
288
+ }
289
+
290
+ // Approval
291
+ const approval = resolveApproval("execute");
292
+ if (approval.blocked) {
293
+ printWarning(`Action blocked: approval mode is set to "never".`);
294
+ return { applied: false, reason: "blocked" };
295
+ }
296
+
297
+ let confirmed = approval.proceed;
298
+ if (!confirmed) {
299
+ confirmed = await askConfirmation(
300
+ `Allow executing: ${chalk.bold(command)}?`
301
+ );
302
+ } else if (approval.autoApproved) {
303
+ console.log(chalk.dim(` ⚡ Auto-approved (${state.approvalMode})`));
304
+ }
305
+
306
+ if (!confirmed) {
307
+ console.log(chalk.yellow(" ↩ Skipped execution."));
308
+ return { applied: false, reason: "declined" };
309
+ }
310
+
311
+ // Execute
312
+ return new Promise((resolve) => {
313
+ const cwd = action.cwd ? path.resolve(state.cwd, action.cwd) : state.cwd;
314
+ const timeout = action.timeout || 30000;
315
+
316
+ exec(command, { cwd, timeout, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
317
+ if (stdout && stdout.trim()) {
318
+ console.log(
319
+ boxen(chalk.gray(stdout.trimEnd()), {
320
+ title: chalk.dim(" stdout "),
321
+ titleAlignment: "left",
322
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
323
+ borderStyle: "single",
324
+ borderColor: "gray",
325
+ dimBorder: true,
326
+ })
327
+ );
328
+ }
329
+ if (stderr && stderr.trim()) {
330
+ console.log(
331
+ boxen(chalk.red(stderr.trimEnd()), {
332
+ title: chalk.dim(" stderr "),
333
+ titleAlignment: "left",
334
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
335
+ borderStyle: "single",
336
+ borderColor: "red",
337
+ dimBorder: true,
338
+ })
339
+ );
340
+ }
341
+ if (err) {
342
+ printError(`Command failed with exit code ${err.code || 1}`);
343
+ resolve({ applied: false, reason: "error", exitCode: err.code, stderr });
344
+ } else {
345
+ printSuccess("Command executed successfully.");
346
+ resolve({ applied: true, stdout, stderr });
347
+ }
348
+ });
349
+ });
350
+ }
351
+
352
+ /**
353
+ * type='patch' — Apply a unified diff / patch to an existing file.
354
+ * Reads the file, applies line-by-line additions/removals, writes back.
355
+ */
356
+ async function handlePatch(action) {
357
+ const filePath = resolvePath(action.file);
358
+ const relPath = action.file;
359
+
360
+ if (!fs.existsSync(filePath)) {
361
+ printError(`Cannot patch "${relPath}": file does not exist.`);
362
+ return { applied: false, reason: "not_found" };
363
+ }
364
+
365
+ const original = fs.readFileSync(filePath, "utf-8");
366
+ let patched;
367
+
368
+ if (action.search && action.replace !== undefined) {
369
+ // Simple search-and-replace patch
370
+ if (!original.includes(action.search)) {
371
+ printError(`Patch failed: could not find the search text in "${relPath}".`);
372
+ return { applied: false, reason: "search_not_found" };
40
373
  }
374
+ patched = original.replace(action.search, action.replace);
375
+ } else if (action.diff) {
376
+ // Unified diff format — apply manually
377
+ patched = applyUnifiedDiff(original, action.diff);
378
+ if (patched === null) {
379
+ printError(`Patch failed: could not apply the diff to "${relPath}".`);
380
+ return { applied: false, reason: "diff_apply_failed" };
381
+ }
382
+ } else if (action.content) {
383
+ // Full replacement content provided
384
+ patched = action.content;
385
+ } else {
386
+ printError(`Patch action missing "search"/"replace", "diff", or "content" field.`);
387
+ return { applied: false, reason: "invalid_patch" };
388
+ }
41
389
 
42
- if (confirm) {
43
- fs.mkdirSync(path.dirname(filepath), { recursive: true });
44
- fs.writeFileSync(filepath, action.content, "utf-8");
45
- printSuccess(`Saved ${action.file}`);
46
- } else {
47
- console.log(chalk.yellow("Skipped write."));
390
+ // Show diff
391
+ renderDiffBox(relPath, original, patched);
392
+
393
+ // Approval
394
+ const approval = resolveApproval("patch");
395
+ if (approval.blocked) {
396
+ printWarning(`Action blocked: approval mode is set to "never".`);
397
+ return { applied: false, reason: "blocked" };
398
+ }
399
+
400
+ let confirmed = approval.proceed;
401
+ if (!confirmed) {
402
+ confirmed = await askConfirmation(
403
+ `Allow patching ${chalk.bold(relPath)}?`
404
+ );
405
+ } else if (approval.autoApproved) {
406
+ console.log(chalk.dim(` ⚡ Auto-approved (${getSessionState().approvalMode})`));
407
+ }
408
+
409
+ if (!confirmed) {
410
+ console.log(chalk.yellow(" ↩ Skipped patch."));
411
+ return { applied: false, reason: "declined" };
412
+ }
413
+
414
+ fs.writeFileSync(filePath, patched, "utf-8");
415
+ printSuccess(`Patched ${relPath}`);
416
+ return { applied: true };
417
+ }
418
+
419
+ /**
420
+ * type='delete' — Delete a file with confirmation.
421
+ */
422
+ async function handleDelete(action) {
423
+ const filePath = resolvePath(action.file);
424
+ const relPath = action.file;
425
+
426
+ if (!fs.existsSync(filePath)) {
427
+ printWarning(`File "${relPath}" does not exist — nothing to delete.`);
428
+ return { applied: false, reason: "not_found" };
429
+ }
430
+
431
+ const stat = fs.statSync(filePath);
432
+ const sizeStr = stat.isFile() ? ` (${formatBytes(stat.size)})` : " (directory)";
433
+
434
+ renderBox(
435
+ " 🗑️ Delete ",
436
+ `${chalk.red.bold("File:")} ${relPath}${chalk.dim(sizeStr)}`,
437
+ "red"
438
+ );
439
+
440
+ // Approval
441
+ const approval = resolveApproval("delete");
442
+ if (approval.blocked) {
443
+ printWarning(`Action blocked: approval mode is set to "never".`);
444
+ return { applied: false, reason: "blocked" };
445
+ }
446
+
447
+ let confirmed = approval.proceed;
448
+ if (!confirmed) {
449
+ confirmed = await askConfirmation(
450
+ `Allow deleting ${chalk.bold(relPath)}?`
451
+ );
452
+ } else if (approval.autoApproved) {
453
+ console.log(chalk.dim(` ⚡ Auto-approved (${getSessionState().approvalMode})`));
454
+ }
455
+
456
+ if (!confirmed) {
457
+ console.log(chalk.yellow(" ↩ Skipped deletion."));
458
+ return { applied: false, reason: "declined" };
459
+ }
460
+
461
+ if (stat.isDirectory()) {
462
+ fs.rmSync(filePath, { recursive: true, force: true });
463
+ } else {
464
+ fs.unlinkSync(filePath);
465
+ }
466
+
467
+ printSuccess(`Deleted ${relPath}`);
468
+ return { applied: true };
469
+ }
470
+
471
+ /**
472
+ * type='mkdir' — Create a directory (recursively).
473
+ */
474
+ async function handleMkdir(action) {
475
+ const dirPath = resolvePath(action.path || action.file);
476
+ const relPath = action.path || action.file;
477
+
478
+ if (fs.existsSync(dirPath)) {
479
+ printWarning(`Directory "${relPath}" already exists.`);
480
+ return { applied: true, reason: "already_exists" };
481
+ }
482
+
483
+ renderBox(
484
+ " 📁 Create Directory ",
485
+ chalk.cyan(relPath),
486
+ "cyan"
487
+ );
488
+
489
+ // Approval
490
+ const approval = resolveApproval("mkdir");
491
+ if (approval.blocked) {
492
+ printWarning(`Action blocked: approval mode is set to "never".`);
493
+ return { applied: false, reason: "blocked" };
494
+ }
495
+
496
+ let confirmed = approval.proceed;
497
+ if (!confirmed) {
498
+ confirmed = await askConfirmation(
499
+ `Allow creating directory ${chalk.bold(relPath)}?`
500
+ );
501
+ } else if (approval.autoApproved) {
502
+ console.log(chalk.dim(` ⚡ Auto-approved (${getSessionState().approvalMode})`));
503
+ }
504
+
505
+ if (!confirmed) {
506
+ console.log(chalk.yellow(" ↩ Skipped mkdir."));
507
+ return { applied: false, reason: "declined" };
508
+ }
509
+
510
+ fs.mkdirSync(dirPath, { recursive: true });
511
+ printSuccess(`Created directory ${relPath}`);
512
+ return { applied: true };
513
+ }
514
+
515
+ /**
516
+ * type='read' — Read and display file content in a code box.
517
+ */
518
+ async function handleRead(action) {
519
+ const filePath = resolvePath(action.file);
520
+ const relPath = action.file;
521
+
522
+ if (!fs.existsSync(filePath)) {
523
+ printError(`File "${relPath}" does not exist.`);
524
+ return { applied: false, reason: "not_found" };
525
+ }
526
+
527
+ const stat = fs.statSync(filePath);
528
+
529
+ if (stat.isDirectory()) {
530
+ const entries = fs.readdirSync(filePath);
531
+ const listing = entries
532
+ .map((entry) => {
533
+ const entryPath = path.join(filePath, entry);
534
+ const entryStat = fs.statSync(entryPath);
535
+ const icon = entryStat.isDirectory() ? "📁" : "📄";
536
+ const size = entryStat.isFile() ? chalk.dim(` (${formatBytes(entryStat.size)})`) : "";
537
+ return ` ${icon} ${entry}${size}`;
538
+ })
539
+ .join("\n");
540
+ renderBox(` 📂 Directory: ${relPath} `, listing || chalk.dim(" (empty)"), "blue");
541
+ return { applied: true, content: entries };
542
+ }
543
+
544
+ // Refuse to display very large binary files
545
+ if (stat.size > 1024 * 1024) {
546
+ printWarning(
547
+ `File "${relPath}" is ${formatBytes(stat.size)} — too large to display. Showing first 200 lines.`
548
+ );
549
+ const content = fs.readFileSync(filePath, "utf-8");
550
+ const truncated = content.split("\n").slice(0, 200).join("\n");
551
+ renderCodeBox(relPath, truncated, detectLanguage(relPath));
552
+ return { applied: true, truncated: true, content: truncated };
553
+ }
554
+
555
+ const content = fs.readFileSync(filePath, "utf-8");
556
+ const lineCount = content.split("\n").length;
557
+
558
+ console.log(
559
+ chalk.dim(
560
+ ` ${lineCount} lines · ${formatBytes(stat.size)} · ${detectLanguage(relPath)}`
561
+ )
562
+ );
563
+ renderCodeBox(relPath, content, detectLanguage(relPath));
564
+ return { applied: true, content };
565
+ }
566
+
567
+ // ──────────────────────────────────────────────
568
+ // Unified diff applier
569
+ // ──────────────────────────────────────────────
570
+
571
+ /**
572
+ * Applies a unified-format diff string to the original file content.
573
+ * Handles lines starting with '+', '-', ' ', and hunk headers (@@ ... @@).
574
+ * Returns the patched string or null if application fails.
575
+ */
576
+ function applyUnifiedDiff(original, diff) {
577
+ const originalLines = original.split("\n");
578
+ const diffLines = diff.split("\n");
579
+ const result = [];
580
+ let origIdx = 0;
581
+
582
+ for (let i = 0; i < diffLines.length; i++) {
583
+ const line = diffLines[i];
584
+
585
+ // Skip diff metadata lines
586
+ if (
587
+ line.startsWith("---") ||
588
+ line.startsWith("+++") ||
589
+ line.startsWith("diff ") ||
590
+ line.startsWith("index ")
591
+ ) {
592
+ continue;
48
593
  }
49
594
 
50
- } else if (action.type === "execute") {
51
- // Sandbox Check
52
- if (state.sandboxMode !== "danger-full-access") {
53
- console.log(chalk.red("\n✖ Action blocked: Command execution requires 'danger-full-access' sandbox mode."));
54
- return;
595
+ // Hunk header: @@ -start,count +start,count @@
596
+ const hunkMatch = line.match(/^@@\s+-(\d+)(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@/);
597
+ if (hunkMatch) {
598
+ const hunkStart = parseInt(hunkMatch[1], 10) - 1; // convert to 0-indexed
599
+ // Copy all lines from current position up to hunk start
600
+ while (origIdx < hunkStart && origIdx < originalLines.length) {
601
+ result.push(originalLines[origIdx]);
602
+ origIdx++;
603
+ }
604
+ continue;
55
605
  }
56
606
 
57
- renderBox(` Execute Command `, action.command, "magenta");
58
-
59
- // Approval Check
60
- let confirm = true;
61
- if (state.approvalMode !== "full-auto") {
62
- const answers = await inquirer.prompt([
63
- {
64
- type: "confirm",
65
- name: "confirm",
66
- message: `Allow RafayGen to execute this command?`,
67
- default: true
68
- }
69
- ]);
70
- confirm = answers.confirm;
607
+ if (line.startsWith("+")) {
608
+ // Added line — insert into result, don't advance original
609
+ result.push(line.substring(1));
610
+ } else if (line.startsWith("-")) {
611
+ // Removed line skip in original
612
+ origIdx++;
613
+ } else if (line.startsWith(" ") || line === "") {
614
+ // Context line — copy from original
615
+ if (origIdx < originalLines.length) {
616
+ result.push(originalLines[origIdx]);
617
+ }
618
+ origIdx++;
71
619
  }
620
+ }
621
+
622
+ // Append any remaining original lines after the last hunk
623
+ while (origIdx < originalLines.length) {
624
+ result.push(originalLines[origIdx]);
625
+ origIdx++;
626
+ }
627
+
628
+ return result.join("\n");
629
+ }
630
+
631
+ // ──────────────────────────────────────────────
632
+ // Main dispatcher
633
+ // ──────────────────────────────────────────────
634
+
635
+ const ACTION_HANDLERS = {
636
+ write: handleWrite,
637
+ execute: handleExecute,
638
+ patch: handlePatch,
639
+ delete: handleDelete,
640
+ mkdir: handleMkdir,
641
+ read: handleRead,
642
+ };
643
+
644
+ /**
645
+ * Main entry point — dispatches an action object from the backend.
646
+ *
647
+ * action shape:
648
+ * { type: 'write', file: string, content: string }
649
+ * { type: 'execute', command: string, cwd?: string, timeout?: number, description?: string }
650
+ * { type: 'patch', file: string, search?: string, replace?: string, diff?: string, content?: string }
651
+ * { type: 'delete', file: string }
652
+ * { type: 'mkdir', path: string } (also accepts file: string)
653
+ * { type: 'read', file: string }
654
+ */
655
+ export async function executeAction(action) {
656
+ if (!action || !action.type) {
657
+ printError("Invalid action: missing type.");
658
+ return { applied: false, reason: "invalid" };
659
+ }
660
+
661
+ const actionType = action.type.toLowerCase();
72
662
 
73
- if (confirm) {
74
- await new Promise((resolve) => {
75
- exec(action.command, { cwd: state.cwd }, (err, stdout, stderr) => {
76
- if (stdout) console.log(chalk.gray(stdout));
77
- if (stderr) console.log(chalk.red(stderr));
78
- if (err) printError(`Command failed with code ${err.code}`);
79
- else printSuccess("Command executed successfully.");
80
- resolve();
81
- });
82
- });
83
- } else {
84
- console.log(chalk.yellow("Skipped execution."));
663
+ // 1. Sandbox check
664
+ const targetPath = action.file || action.path || null;
665
+ const sandboxResult = checkSandbox(actionType, targetPath);
666
+ if (!sandboxResult.allowed) {
667
+ printError(`Sandbox blocked: ${sandboxResult.reason}`);
668
+ return { applied: false, reason: "sandbox_blocked", detail: sandboxResult.reason };
669
+ }
670
+
671
+ // 2. Dispatch to handler
672
+ const handler = ACTION_HANDLERS[actionType];
673
+ if (!handler) {
674
+ printError(`Unknown action type: "${action.type}".`);
675
+ return { applied: false, reason: "unknown_type" };
676
+ }
677
+
678
+ try {
679
+ return await handler(action);
680
+ } catch (err) {
681
+ printError(`Action "${actionType}" failed: ${err.message}`);
682
+ if (getSessionState().verbose) {
683
+ console.error(chalk.dim(err.stack));
85
684
  }
685
+ return { applied: false, reason: "error", error: err.message };
86
686
  }
87
687
  }
688
+
689
+ /**
690
+ * Executes a batch of actions sequentially.
691
+ * Returns an array of results, one per action.
692
+ */
693
+ export async function executeActions(actions) {
694
+ if (!Array.isArray(actions) || actions.length === 0) {
695
+ return [];
696
+ }
697
+
698
+ const results = [];
699
+ const total = actions.length;
700
+
701
+ console.log(
702
+ chalk.cyan.bold(`\n━━━ Executing ${total} action${total > 1 ? "s" : ""} ━━━\n`)
703
+ );
704
+
705
+ for (let i = 0; i < actions.length; i++) {
706
+ const action = actions[i];
707
+ console.log(
708
+ chalk.dim(`[${i + 1}/${total}] `) +
709
+ chalk.bold(action.type) +
710
+ chalk.dim(action.file ? ` → ${action.file}` : action.command ? ` → ${action.command}` : "")
711
+ );
712
+
713
+ const result = await executeAction(action);
714
+ results.push({ action: action.type, ...result });
715
+
716
+ // If an action fails with sandbox or never-mode block, continue with others
717
+ // but if it was a hard error in execute, optionally stop
718
+ if (result.reason === "error" && action.type === "execute" && action.stopOnError !== false) {
719
+ printWarning("Stopping batch execution due to command failure.");
720
+ break;
721
+ }
722
+ }
723
+
724
+ // Summary
725
+ const applied = results.filter((r) => r.applied).length;
726
+ const skipped = results.filter((r) => !r.applied).length;
727
+
728
+ console.log(
729
+ chalk.dim(`\n━━━ Done: `) +
730
+ chalk.green.bold(`${applied} applied`) +
731
+ chalk.dim(", ") +
732
+ (skipped > 0 ? chalk.yellow.bold(`${skipped} skipped`) : chalk.dim("0 skipped")) +
733
+ chalk.dim(` ━━━\n`)
734
+ );
735
+
736
+ return results;
737
+ }