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<Node> - 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}