001/* 002 * JDrupes Builder 003 * Copyright (C) 2026 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.builder.ext.nodejs; 020 021import com.google.common.flogger.FluentLogger; 022import java.io.File; 023import java.io.IOException; 024import java.io.InputStream; 025import java.io.OutputStream; 026import java.lang.ProcessBuilder.Redirect; 027import java.nio.file.Path; 028import java.util.ArrayList; 029import java.util.Arrays; 030import java.util.List; 031import java.util.Objects; 032import java.util.function.Function; 033import java.util.stream.Collectors; 034import java.util.stream.Stream; 035import org.jdrupes.builder.api.BuildException; 036import org.jdrupes.builder.api.Cleanliness; 037import org.jdrupes.builder.api.ExecResult; 038import org.jdrupes.builder.api.FileResource; 039import org.jdrupes.builder.api.FileTree; 040import org.jdrupes.builder.api.Project; 041import org.jdrupes.builder.api.Renamable; 042import org.jdrupes.builder.api.Resource; 043import org.jdrupes.builder.api.ResourceProvider; 044import org.jdrupes.builder.api.ResourceRequest; 045import org.jdrupes.builder.api.ResourceType; 046import static org.jdrupes.builder.api.ResourceType.*; 047import org.jdrupes.builder.api.Resources; 048import org.jdrupes.builder.core.AbstractProvider; 049import org.jdrupes.builder.core.StreamCollector; 050 051/// A provider for that invokes `npm init`. 052/// 053/// This provider is made available as an extension. 054/// [ 056/// ](https://mvnrepository.com/artifact/org.jdrupes/jdbld-ext-nodejs) 057/// 058public class NpmInitializer extends AbstractProvider implements Renamable { 059 060 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 061 private final Project project; 062 private String nodeJsVersion; 063 private NodeJsDownloader nodeJsDownloader; 064 065 /// Initializes a new NPM initializer. 066 /// 067 /// @param project the project 068 /// 069 public NpmInitializer(Project project) { 070 this.project = project; 071 rename(NpmInitializer.class.getSimpleName() + " in " + project); 072 } 073 074 @Override 075 public NpmInitializer name(String name) { 076 rename(name); 077 return this; 078 } 079 080 /// Sets the node.js version to use. Setting a version is mandatory. 081 /// 082 /// @param version the version 083 /// @return the npm executor 084 /// 085 public NpmInitializer nodeJsVersion(String version) { 086 nodeJsVersion = version; 087 return this; 088 } 089 090 @Override 091 @SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.CognitiveComplexity" }) 092 protected <T extends Resource> Stream<T> 093 doProvide(ResourceRequest<T> request) { 094 // Check for and handle request for execution result 095 if (!request.accepts(ExecResultType) 096 || !name().equals(request.name().orElse(null))) { 097 return Stream.empty(); 098 } 099 // Always evaluate for most special type 100 if (!request.type().equals(ExecResultType)) { 101 @SuppressWarnings("unchecked") 102 var result = (Stream<T>) resources(of(ExecResultType) 103 .withName(name())); 104 return result; 105 } 106 107 // Check prerequisites 108 if (nodeJsVersion == null) { 109 throw new BuildException().from(this) 110 .message("No node.js version specified"); 111 } 112 nodeJsDownloader = new NodeJsDownloader(this, context() 113 .commonCacheDirectory().resolve(getClass().getPackageName())); 114 File packageJson = project.directory().resolve("package.json").toFile(); 115 if (!packageJson.canRead()) { 116 throw new BuildException().from(this) 117 .message("No package.json in %s", project); 118 } 119 File dotPackageLock = project.directory() 120 .resolve("node_modules/.package-lock.json").toFile(); 121 if (!project.directory().resolve("node_modules").toFile().exists() 122 || !dotPackageLock.exists() 123 || packageJson.lastModified() > dotPackageLock.lastModified()) { 124 logger.atConfig().log("Updating node_modules in %s", project); 125 runNpm(project, List.of("install")); 126 } 127 // var Result = ExecResult.from(this, 0); 128 return Stream.empty(); 129 } 130 131 private <T extends Resource> Stream<T> runNpm( 132 Project project, List<String> arguments) { 133 var nodeJsExecutable = nodeJsDownloader.npmExecutable(nodeJsVersion); 134 logger.atFine().log("Running %s with %s", this, nodeJsExecutable); 135 List<String> command 136 = new ArrayList<>(List.of(nodeJsExecutable.toString())); 137 command.addAll(arguments); 138 ProcessBuilder processBuilder = new ProcessBuilder(command) 139 .directory(project.directory().toFile()) 140 .redirectInput(Redirect.INHERIT); 141 try { 142 Process process = processBuilder.start(); 143 copyData(process.getInputStream(), context().out()); 144 copyData(process.getErrorStream(), context().error()); 145 @SuppressWarnings("unchecked") 146 var result = (Stream<T>) Stream.of(ExecResult.from(this, 147 "[" + project.name() + "]$ npm " 148 + arguments.stream().collect(Collectors.joining(" ")), 149 process.waitFor())); 150 return result; 151 } catch (IOException | InterruptedException e) { 152 throw new BuildException().from(this).cause(e); 153 } 154 } 155 156 private void copyData(InputStream source, OutputStream sink) { 157 Thread.startVirtualThread(() -> { 158 try (source) { 159 source.transferTo(sink); 160 } catch (IOException e) { // NOPMD 161 } 162 }); 163 } 164}