001/*
002 * Copyright 2025 devteam@scivicslab.com
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing,
011 * software distributed under the License is distributed on an
012 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
013 * either express or implied.  See the License for the
014 * specific language governing permissions and limitations
015 * under the License.
016 */
017
018package com.scivicslab.actoriac;
019
020import java.io.BufferedReader;
021import java.io.IOException;
022import java.io.InputStreamReader;
023import java.nio.file.Files;
024import java.nio.file.Path;
025import java.util.HashSet;
026import java.util.Set;
027import java.util.logging.Level;
028import java.util.logging.Logger;
029
030import org.json.JSONObject;
031
032import com.scivicslab.pojoactor.core.ActionResult;
033import com.scivicslab.pojoactor.workflow.IIActorRef;
034import com.scivicslab.pojoactor.workflow.IIActorSystem;
035import com.scivicslab.pojoactor.workflow.Interpreter;
036import com.scivicslab.pojoactor.workflow.Transition;
037
038/**
039 * Level 3 wrapper that adds workflow capabilities to a Node POJO.
040 *
041 * <p>This class extends {@link Interpreter} to provide workflow execution
042 * capabilities while delegating SSH operations to a wrapped {@link Node} instance.</p>
043 *
044 * <p>This demonstrates the three-level architecture of actor-IaC:</p>
045 * <ul>
046 * <li><strong>Level 1 (POJO):</strong> {@link Node} - pure POJO with SSH functionality</li>
047 * <li><strong>Level 2 (Actor):</strong> ActorRef&lt;Node&gt; - actor wrapper for concurrent execution</li>
048 * <li><strong>Level 3 (Workflow):</strong> NodeInterpreter - workflow capabilities + IIActorRef wrapper</li>
049 * </ul>
050 *
051 * <p><strong>Design principle:</strong> Node remains a pure POJO, independent of ActorSystem.
052 * NodeInterpreter wraps Node to add workflow capabilities without modifying the Node class.</p>
053 *
054 * @author devteam@scivicslab.com
055 */
056public class NodeInterpreter extends Interpreter {
057
058    private static final Logger logger = Logger.getLogger(NodeInterpreter.class.getName());
059
060    /**
061     * The wrapped Node POJO that handles actual SSH operations.
062     */
063    private final Node node;
064
065    /**
066     * The overlay directory path for YAML overlay feature.
067     */
068    private String overlayDir;
069
070    /**
071     * The current transition YAML snippet (first 10 lines) for accumulator reporting.
072     */
073    private String currentTransitionYaml = "";
074
075    /**
076     * Set of changed document names detected by workflow.
077     * This replaces the /tmp/changed_docs.txt file-based approach.
078     */
079    private final Set<String> changedDocuments = new HashSet<>();
080
081    /**
082     * IaCStreamingAccumulator for cowsay display.
083     * When set, workflow step transitions are displayed using cowsay.
084     */
085    private IaCStreamingAccumulator accumulator = null;
086
087    /**
088     * Constructs a NodeInterpreter that wraps the specified Node.
089     *
090     * @param node the {@link Node} instance to wrap
091     * @param system the actor system for workflow execution
092     */
093    public NodeInterpreter(Node node, IIActorSystem system) {
094        super();
095        this.node = node;
096        this.system = system;
097        // Initialize parent's logger (used by Interpreter.runWorkflow error handling)
098        super.logger = logger;
099    }
100
101    /**
102     * Executes a command on the remote node via SSH.
103     *
104     * <p>Delegates to the wrapped {@link Node#executeCommand(String)} method.</p>
105     *
106     * @param command the command to execute
107     * @return the result of the command execution
108     * @throws IOException if SSH connection fails
109     */
110    public Node.CommandResult executeCommand(String command) throws IOException {
111        return node.executeCommand(command);
112    }
113
114    /**
115     * Executes a command on the remote node via SSH with real-time output callback.
116     *
117     * <p>Delegates to the wrapped {@link Node#executeCommand(String, Node.OutputCallback)} method.</p>
118     *
119     * @param command the command to execute
120     * @param callback the callback for real-time output (may be null)
121     * @return the result of the command execution
122     * @throws IOException if SSH connection fails
123     */
124    public Node.CommandResult executeCommand(String command, Node.OutputCallback callback) throws IOException {
125        return node.executeCommand(command, callback);
126    }
127
128    /**
129     * Executes a command with sudo privileges on the remote node.
130     *
131     * <p>Delegates to the wrapped {@link Node#executeSudoCommand(String)} method.
132     * Requires SUDO_PASSWORD environment variable to be set.</p>
133     *
134     * @param command the command to execute with sudo
135     * @return the result of the command execution
136     * @throws IOException if SSH connection fails or SUDO_PASSWORD is not set
137     */
138    public Node.CommandResult executeSudoCommand(String command) throws IOException {
139        return node.executeSudoCommand(command);
140    }
141
142    /**
143     * Executes a command with sudo privileges on the remote node with real-time output callback.
144     *
145     * <p>Delegates to the wrapped {@link Node#executeSudoCommand(String, Node.OutputCallback)} method.
146     * Requires SUDO_PASSWORD environment variable to be set.</p>
147     *
148     * @param command the command to execute with sudo
149     * @param callback the callback for real-time output (may be null)
150     * @return the result of the command execution
151     * @throws IOException if SSH connection fails or SUDO_PASSWORD is not set
152     */
153    public Node.CommandResult executeSudoCommand(String command, Node.OutputCallback callback) throws IOException {
154        return node.executeSudoCommand(command, callback);
155    }
156
157    /**
158     * Gets the hostname of the node.
159     *
160     * @return the hostname
161     */
162    public String getHostname() {
163        return node.getHostname();
164    }
165
166    /**
167     * Gets the username for SSH connections.
168     *
169     * @return the username
170     */
171    public String getUser() {
172        return node.getUser();
173    }
174
175    /**
176     * Gets the SSH port.
177     *
178     * @return the SSH port number
179     */
180    public int getPort() {
181        return node.getPort();
182    }
183
184    /**
185     * Gets the wrapped Node instance.
186     *
187     * <p>This allows direct access to the underlying POJO when needed.</p>
188     *
189     * @return the wrapped Node
190     */
191    public Node getNode() {
192        return node;
193    }
194
195    /**
196     * Sets the overlay directory for YAML overlay feature.
197     *
198     * @param overlayDir the path to the overlay directory containing overlay-conf.yaml
199     */
200    public void setOverlayDir(String overlayDir) {
201        this.overlayDir = overlayDir;
202    }
203
204    /**
205     * Gets the overlay directory path.
206     *
207     * @return the overlay directory path, or null if not set
208     */
209    public String getOverlayDir() {
210        return overlayDir;
211    }
212
213    /**
214     * Sets the IaCStreamingAccumulator for cowsay display.
215     *
216     * @param accumulator the accumulator to use for cowsay display
217     */
218    public void setAccumulator(IaCStreamingAccumulator accumulator) {
219        this.accumulator = accumulator;
220    }
221
222    /**
223     * Gets the IaCStreamingAccumulator for cowsay display.
224     *
225     * @return the cowsay accumulator, or null if not set
226     */
227    public IaCStreamingAccumulator getAccumulator() {
228        return accumulator;
229    }
230
231    /**
232     * Hook called when entering a transition during workflow execution.
233     *
234     * <p>Displays the workflow name and first 10 lines of the transition definition
235     * in YAML format using cowsay to provide visual separation between workflow steps.</p>
236     *
237     * @param transition the transition being entered
238     */
239    @Override
240    protected void onEnterTransition(Transition transition) {
241        // Get workflow name
242        String workflowName = (getCode() != null && getCode().getName() != null)
243                ? getCode().getName()
244                : "unknown-workflow";
245
246        // Get YAML-formatted output (first 10 lines)
247        String yamlText = transition.toYamlString(10).trim();
248        this.currentTransitionYaml = yamlText;
249
250        // Render cowsay output
251        String cowsayOutput;
252        if (accumulator != null) {
253            cowsayOutput = accumulator.renderCowsay(workflowName, yamlText);
254        } else {
255            // Fallback to simple text if no accumulator
256            cowsayOutput = "[" + workflowName + "]\n" + yamlText;
257        }
258
259        // Send cowsay output to outputMultiplexer (loose coupling via ActorSystem)
260        IIActorRef<?> multiplexer = system.getIIActor("outputMultiplexer");
261        if (multiplexer != null) {
262            JSONObject arg = new JSONObject();
263            // Use actor name as source (consistent across all output)
264            arg.put("source", selfActorRef != null ? selfActorRef.getName() : "unknown");
265            arg.put("type", "cowsay");
266            arg.put("data", cowsayOutput);
267            multiplexer.callByActionName("add", arg.toString());
268        }
269    }
270
271    /**
272     * Hook called after a transition completes (success or failure).
273     *
274     * <p>Logs the transition result to the outputMultiplexer for
275     * workflow execution reporting.</p>
276     *
277     * @param transition the transition that was attempted
278     * @param success true if the transition succeeded, false if it failed
279     * @param result the ActionResult from executing the transition's actions
280     */
281    @Override
282    protected void onExitTransition(Transition transition, boolean success, ActionResult result) {
283        String label = transition.getLabel();
284        if (label == null && transition.getStates() != null && transition.getStates().size() >= 2) {
285            label = transition.getStates().get(0) + " -> " + transition.getStates().get(1);
286        }
287
288        // noteを取得(60文字または最初の行まで)
289        String note = getTransitionNote(transition);
290
291        String status = success ? "SUCCESS" : "FAILED";
292        String resultMsg = result != null ? result.getResult() : "";
293        // Truncate long result messages
294        if (resultMsg.length() > 500) {
295            resultMsg = resultMsg.substring(0, 500) + "...";
296        }
297        // noteがあればメッセージに含める
298        String message = "Transition " + status + ": " + label +
299                (note.isEmpty() ? "" : " [" + note + "]") +
300                (resultMsg.isEmpty() ? "" : " - " + resultMsg);
301
302        // Send to outputMultiplexer
303        IIActorRef<?> multiplexer = system.getIIActor("outputMultiplexer");
304        if (multiplexer != null) {
305            JSONObject arg = new JSONObject();
306            arg.put("source", selfActorRef != null ? selfActorRef.getName() : "unknown");
307            arg.put("type", success ? "transition-success" : "transition-failed");
308            arg.put("label", label);
309            arg.put("data", message);
310            multiplexer.callByActionName("add", arg.toString());
311        }
312    }
313
314    /**
315     * transitionのnoteを取得する。60文字または最初の行までに制限。
316     * noteがない場合は空文字列を返す。
317     */
318    private String getTransitionNote(Transition transition) {
319        String note = transition.getNote();
320        if (note == null || note.isEmpty()) {
321            return "";
322        }
323        // 最初の行のみ
324        int newlineIdx = note.indexOf('\n');
325        if (newlineIdx > 0) {
326            note = note.substring(0, newlineIdx);
327        }
328        // 60文字まで
329        if (note.length() > 60) {
330            note = note.substring(0, 57) + "...";
331        }
332        return note.trim();
333    }
334
335    /**
336     * Returns the current transition YAML snippet for accumulator reporting.
337     *
338     * @return the first 10 lines of the current transition in YAML format
339     */
340    public String getCurrentTransitionYaml() {
341        return currentTransitionYaml;
342    }
343
344    /**
345     * Loads and runs a workflow file to completion with overlay support.
346     *
347     * <p>If overlayDir is set, the workflow is loaded with overlay applied.
348     * Variables defined in overlay-conf.yaml are substituted before execution.</p>
349     *
350     * @param workflowFile the workflow file path (YAML or JSON)
351     * @param maxIterations maximum number of state transitions allowed
352     * @return ActionResult with success=true if completed, false otherwise
353     */
354    @Override
355    public ActionResult runWorkflow(String workflowFile, int maxIterations) {
356        // If no overlay is set, use parent implementation
357        if (overlayDir == null) {
358            return super.runWorkflow(workflowFile, maxIterations);
359        }
360
361        try {
362            // Reset state for fresh execution
363            reset();
364
365            // Resolve workflow file path
366            Path workflowPath;
367            if (workflowBaseDir != null) {
368                workflowPath = Path.of(workflowBaseDir, workflowFile);
369            } else {
370                workflowPath = Path.of(workflowFile);
371            }
372
373            // Load workflow with overlay applied
374            Path overlayPath = Path.of(overlayDir);
375            readYaml(workflowPath, overlayPath);
376
377            // Run until end
378            return runUntilEnd(maxIterations);
379
380        } catch (Exception e) {
381            logger.log(Level.SEVERE, "Error running workflow with overlay: " + workflowFile, e);
382            return new ActionResult(false, "Error: " + e.getMessage());
383        }
384    }
385
386    // ========================================================================
387    // Document Change Detection API (replaces /tmp file-based approach)
388    // ========================================================================
389
390    /**
391     * Detects changed documents and stores them in POJO state.
392     *
393     * <p>This method replaces the shell script that wrote to /tmp/changed_docs.txt.
394     * It reads the document list, checks git status for each, and stores changed
395     * document names in the changedDocuments set.</p>
396     *
397     * @param docListPath path to the document list file
398     * @return ActionResult with detection summary
399     * @throws IOException if file operations fail
400     */
401    public ActionResult detectDocumentChanges(String docListPath) throws IOException {
402        // Clear previous results
403        changedDocuments.clear();
404
405        // Expand ~ to home directory
406        String expandedPath = docListPath.replace("~", System.getProperty("user.home"));
407        Path listPath = Path.of(expandedPath);
408
409        if (!Files.exists(listPath)) {
410            return new ActionResult(false, "Document list not found: " + docListPath);
411        }
412
413        // Check for FORCE_FULL_BUILD environment variable
414        boolean forceBuild = "true".equalsIgnoreCase(System.getenv("FORCE_FULL_BUILD"));
415
416        StringBuilder summary = new StringBuilder();
417
418        if (forceBuild) {
419            summary.append("=== FORCE_FULL_BUILD enabled: processing all documents ===\n");
420        } else {
421            summary.append("=== Detecting changes via git fetch ===\n");
422        }
423
424        try (BufferedReader reader = Files.newBufferedReader(listPath)) {
425            String line;
426            while ((line = reader.readLine()) != null) {
427                line = line.trim();
428
429                // Skip comments and empty lines
430                if (line.isEmpty() || line.startsWith("#")) {
431                    continue;
432                }
433
434                // Parse: path git_url
435                String[] parts = line.split("\\s+", 2);
436                String path = parts[0].replace("~", System.getProperty("user.home"));
437                String docName = Path.of(path).getFileName().toString();
438
439                if (forceBuild) {
440                    changedDocuments.add(docName);
441                    summary.append("  [FORCE] ").append(docName).append("\n");
442                    continue;
443                }
444
445                // Check document status
446                Path docPath = Path.of(path);
447                Path gitPath = docPath.resolve(".git");
448
449                if (!Files.exists(docPath)) {
450                    // New document
451                    changedDocuments.add(docName);
452                    summary.append("  [NEW] ").append(docName).append("\n");
453                } else if (!Files.exists(gitPath)) {
454                    // Not a git repository
455                    changedDocuments.add(docName);
456                    summary.append("  [NO-GIT] ").append(docName).append("\n");
457                } else {
458                    // Check for remote changes using git
459                    String status = checkGitStatus(docPath);
460                    if (status.startsWith("[CHANGED]") || status.startsWith("[UNKNOWN]")) {
461                        changedDocuments.add(docName);
462                    }
463                    summary.append("  ").append(status).append(" ").append(docName).append("\n");
464                }
465            }
466        }
467
468        summary.append("\n=== Change detection summary ===\n");
469        if (changedDocuments.isEmpty()) {
470            summary.append("No changes detected. All documents are up to date.\n");
471        } else {
472            summary.append("Documents to process: ").append(changedDocuments.size()).append("\n");
473            for (String doc : changedDocuments) {
474                summary.append(doc).append("\n");
475            }
476        }
477
478        // Print summary (like the original shell script did)
479        System.out.println(summary);
480
481        return new ActionResult(true, "Detected " + changedDocuments.size() + " changed documents");
482    }
483
484    /**
485     * Checks git status for a document directory.
486     *
487     * @param docPath path to the document directory
488     * @return status string like "[CHANGED]", "[UP-TO-DATE]", or "[UNKNOWN]"
489     */
490    private String checkGitStatus(Path docPath) {
491        try {
492            // git fetch
493            ProcessBuilder fetchPb = new ProcessBuilder("git", "fetch", "origin");
494            fetchPb.directory(docPath.toFile());
495            fetchPb.redirectErrorStream(true);
496            Process fetchProcess = fetchPb.start();
497            fetchProcess.waitFor();
498
499            // Get local HEAD
500            String local = runGitCommand(docPath, "git", "rev-parse", "HEAD");
501
502            // Get remote HEAD (try main first, then master)
503            String remote = runGitCommand(docPath, "git", "rev-parse", "origin/main");
504            if (remote == null || remote.equals("unknown")) {
505                remote = runGitCommand(docPath, "git", "rev-parse", "origin/master");
506            }
507
508            if (local == null || remote == null || local.equals("unknown") || remote.equals("unknown")) {
509                return "[UNKNOWN] (cannot determine state)";
510            }
511
512            if (!local.equals(remote)) {
513                String localShort = local.length() > 7 ? local.substring(0, 7) : local;
514                String remoteShort = remote.length() > 7 ? remote.substring(0, 7) : remote;
515                return "[CHANGED] (local: " + localShort + ", remote: " + remoteShort + ")";
516            }
517
518            return "[UP-TO-DATE]";
519
520        } catch (Exception e) {
521            logger.log(Level.WARNING, "Error checking git status for " + docPath, e);
522            return "[UNKNOWN] (error: " + e.getMessage() + ")";
523        }
524    }
525
526    /**
527     * Runs a git command and returns the output.
528     */
529    private String runGitCommand(Path workDir, String... command) {
530        try {
531            ProcessBuilder pb = new ProcessBuilder(command);
532            pb.directory(workDir.toFile());
533            pb.redirectErrorStream(true);
534            Process process = pb.start();
535
536            try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
537                String line = reader.readLine();
538                process.waitFor();
539                return line != null ? line.trim() : "unknown";
540            }
541        } catch (Exception e) {
542            return "unknown";
543        }
544    }
545
546    /**
547     * Checks if a specific document is in the changed list.
548     *
549     * @param docName the document name to check
550     * @return true if the document was detected as changed
551     */
552    public boolean isDocumentChanged(String docName) {
553        return changedDocuments.contains(docName);
554    }
555
556    /**
557     * Gets the number of changed documents.
558     *
559     * @return the count of changed documents
560     */
561    public int getChangedDocumentsCount() {
562        return changedDocuments.size();
563    }
564
565    /**
566     * Gets all changed document names.
567     *
568     * @return unmodifiable set of changed document names
569     */
570    public Set<String> getChangedDocuments() {
571        return Set.copyOf(changedDocuments);
572    }
573
574    /**
575     * Checks if there are any changed documents to process.
576     *
577     * @return true if at least one document needs processing
578     */
579    public boolean hasChangedDocuments() {
580        return !changedDocuments.isEmpty();
581    }
582
583    /**
584     * Clears the changed documents list.
585     */
586    public void clearChangedDocuments() {
587        changedDocuments.clear();
588    }
589
590    /**
591     * Adds a document to the changed list (for testing or manual override).
592     *
593     * @param docName the document name to add
594     */
595    public void addChangedDocument(String docName) {
596        changedDocuments.add(docName);
597    }
598
599    /**
600     * Clones changed documents from git.
601     *
602     * <p>Only clones documents that are in the changedDocuments set.
603     * Removes existing directory and does fresh clone to avoid conflicts.</p>
604     *
605     * @param docListPath path to the document list file
606     * @return ActionResult with clone summary
607     * @throws IOException if operations fail
608     */
609    public ActionResult cloneChangedDocuments(String docListPath) throws IOException {
610        if (changedDocuments.isEmpty()) {
611            System.out.println("=== No documents to clone (all up to date) ===");
612            return new ActionResult(true, "No documents to clone");
613        }
614
615        String expandedPath = docListPath.replace("~", System.getProperty("user.home"));
616        Path listPath = Path.of(expandedPath);
617
618        // Ensure ~/works exists
619        node.executeCommand("mkdir -p ~/works");
620
621        System.out.println("=== Cloning changed documents ===");
622        int clonedCount = 0;
623
624        try (BufferedReader reader = Files.newBufferedReader(listPath)) {
625            String line;
626            while ((line = reader.readLine()) != null) {
627                line = line.trim();
628                if (line.isEmpty() || line.startsWith("#")) continue;
629
630                String[] parts = line.split("\\s+", 2);
631                String path = parts[0];
632                String gitUrl = parts.length > 1 ? parts[1] : null;
633                String docName = Path.of(path).getFileName().toString();
634
635                if (!changedDocuments.contains(docName)) {
636                    System.out.println("  [SKIP] " + docName + " (unchanged)");
637                    continue;
638                }
639
640                if (gitUrl != null && !gitUrl.isEmpty()) {
641                    // Remove old and clone fresh
642                    System.out.println("=== Cloning: " + gitUrl + " -> " + path + " ===");
643                    node.executeCommand("rm -rf " + path);
644                    Node.CommandResult result = node.executeCommand("git clone " + gitUrl + " " + path);
645                    if (result.getExitCode() == 0) {
646                        clonedCount++;
647                    } else {
648                        System.err.println("Clone failed: " + result.getStderr());
649                    }
650                } else {
651                    System.out.println("=== No git URL specified for: " + path + " ===");
652                }
653            }
654        }
655
656        return new ActionResult(true, "Cloned " + clonedCount + " documents");
657    }
658
659    /**
660     * Builds changed Docusaurus documents.
661     *
662     * <p>Only builds documents that are in the changedDocuments set.</p>
663     *
664     * @param docListPath path to the document list file
665     * @return ActionResult with build summary
666     * @throws IOException if operations fail
667     */
668    public ActionResult buildChangedDocuments(String docListPath) throws IOException {
669        if (changedDocuments.isEmpty()) {
670            System.out.println("=== No documents to build (all up to date) ===");
671            return new ActionResult(true, "No documents to build");
672        }
673
674        String expandedPath = docListPath.replace("~", System.getProperty("user.home"));
675        Path listPath = Path.of(expandedPath);
676
677        System.out.println("=== Building changed documents ===");
678        int builtCount = 0;
679
680        try (BufferedReader reader = Files.newBufferedReader(listPath)) {
681            String line;
682            while ((line = reader.readLine()) != null) {
683                line = line.trim();
684                if (line.isEmpty() || line.startsWith("#")) continue;
685
686                String[] parts = line.split("\\s+", 2);
687                String path = parts[0];
688                String docName = Path.of(path).getFileName().toString();
689
690                if (!changedDocuments.contains(docName)) {
691                    System.out.println("  [SKIP] " + docName + " (unchanged)");
692                    continue;
693                }
694
695                System.out.println("=== Building: " + path + " ===");
696                Node.CommandResult result = node.executeCommand(
697                    "cd " + path + " && yarn install && yarn build"
698                );
699                if (result.getExitCode() == 0) {
700                    builtCount++;
701                } else {
702                    System.err.println("Build failed for " + docName + ": " + result.getStderr());
703                }
704            }
705        }
706
707        return new ActionResult(true, "Built " + builtCount + " documents");
708    }
709
710    /**
711     * Copies changed document builds to public_html.
712     *
713     * <p>Only copies documents that are in the changedDocuments set.</p>
714     *
715     * @param docListPath path to the document list file
716     * @return ActionResult with copy summary
717     * @throws IOException if operations fail
718     */
719    public ActionResult deployChangedDocuments(String docListPath) throws IOException {
720        // Ensure public_html exists
721        node.executeCommand("mkdir -p ~/public_html");
722
723        if (changedDocuments.isEmpty()) {
724            System.out.println("=== No documents to copy (all up to date) ===");
725            node.executeCommand("ls -la ~/public_html/");
726            return new ActionResult(true, "No documents to copy");
727        }
728
729        String expandedPath = docListPath.replace("~", System.getProperty("user.home"));
730        Path listPath = Path.of(expandedPath);
731
732        System.out.println("=== Copying changed documents to public_html ===");
733        int copiedCount = 0;
734
735        try (BufferedReader reader = Files.newBufferedReader(listPath)) {
736            String line;
737            while ((line = reader.readLine()) != null) {
738                line = line.trim();
739                if (line.isEmpty() || line.startsWith("#")) continue;
740
741                String[] parts = line.split("\\s+", 2);
742                String path = parts[0];
743                String docName = Path.of(path).getFileName().toString();
744
745                if (!changedDocuments.contains(docName)) {
746                    System.out.println("  [SKIP] " + docName + " (unchanged)");
747                    continue;
748                }
749
750                String buildPath = path + "/build";
751                String destPath = "~/public_html/" + docName;
752
753                System.out.println("=== Copying " + docName + " to public_html ===");
754                node.executeCommand("rm -rf " + destPath);
755                Node.CommandResult result = node.executeCommand(
756                    "cp -r " + buildPath + " " + destPath
757                );
758                if (result.getExitCode() == 0) {
759                    copiedCount++;
760                } else {
761                    System.err.println("Copy failed for " + docName + ": " + result.getStderr());
762                }
763            }
764        }
765
766        System.out.println("=== public_html contents ===");
767        node.executeCommand("ls -la ~/public_html/");
768
769        return new ActionResult(true, "Deployed " + copiedCount + " documents");
770    }
771}