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.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.function.Function; 032import java.util.stream.Collectors; 033import java.util.stream.Stream; 034import org.jdrupes.builder.api.BuildException; 035import org.jdrupes.builder.api.Cleanliness; 036import org.jdrupes.builder.api.ExecResult; 037import org.jdrupes.builder.api.FileResource; 038import org.jdrupes.builder.api.FileTree; 039import org.jdrupes.builder.api.Project; 040import org.jdrupes.builder.api.Renamable; 041import org.jdrupes.builder.api.Resource; 042import org.jdrupes.builder.api.ResourceProvider; 043import org.jdrupes.builder.api.ResourceRequest; 044import org.jdrupes.builder.api.ResourceType; 045import static org.jdrupes.builder.api.ResourceType.*; 046import org.jdrupes.builder.api.Resources; 047import org.jdrupes.builder.core.AbstractProvider; 048 049/// A provider for [execution results][ExecResult]s from invoking npm. 050/// The provider produces resources in response to requests for 051/// [ExecResult]'s where the request's [ResourceRequest#name()] matches 052/// this [provider's name][ResourceProvider#name()]. 053/// 054/// * The provider first checks if a file `package.json` exists, else it 055/// fails. If no directory `node_modules` exists or `package.json` 056/// is newer than `node_modules/.package-lock.json` it invokes `npm init`. 057/// 058/// * Then, the provider retrieves all resources added by [#required]. While 059/// the provider itself does not process these resources, it is assumed 060/// that they are processed by the npm command and therefore need to be 061/// available. 062/// 063/// * The provider invokes the function set with [#provided] and 064/// collects all resources. If the provided resources exist and no 065/// resource from `required` is newer then the provided resources found, 066/// the provider returns a result that indicates successful invocation. 067/// The date of the result is set to the newest date from the provided 068/// resources and the (existing) resources are attached. 069/// 070/// * Else, the provider invokes npm, calls the function set with 071/// `provided` again and adds the result to the [ExecResult] that 072/// it returns. 073/// 074/// The provider also uses the function set with [#provided] to determine 075/// the resources to be removed when it is invoked with a request for 076/// [Cleanliness]. 077/// 078public class NpmExecutor extends AbstractProvider implements Renamable { 079 080 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 081 private final Project project; 082 private final List<String> arguments = new ArrayList<>(); 083 private final List<Stream<Resource>> requiredResources = new ArrayList<>(); 084 private Function<Project, Stream<Resource>> getProvided 085 = _ -> Stream.empty(); 086 087 /// Initializes a new NPM executor. 088 /// 089 /// @param project the project 090 /// 091 public NpmExecutor(Project project) { 092 this.project = project; 093 rename(NpmExecutor.class.getSimpleName() + " in " + project); 094 } 095 096 /// Name. 097 /// 098 /// @param name the name 099 /// @return the npm executor 100 /// 101 @Override 102 public NpmExecutor name(String name) { 103 rename(name); 104 return this; 105 } 106 107 /// Add the given arguments. 108 /// 109 /// @param args the arguments 110 /// @return the npm executor 111 /// 112 public NpmExecutor args(String... args) { 113 arguments.addAll(Arrays.asList(args)); 114 return this; 115 } 116 117 /// Add the given [Stream] of resources to the required resources. 118 /// 119 /// @param resources the resources 120 /// @return the npm executor 121 /// 122 public NpmExecutor required(Stream<Resource> resources) { 123 requiredResources.add(resources); 124 return this; 125 } 126 127 /// Convenience method to add a [FileTree] to the required resources. 128 /// If `root` is a relative path, it is resolved against the project's 129 /// directory. 130 /// 131 /// @param root the root 132 /// @param pattern the pattern 133 /// @return the npm executor 134 /// 135 public NpmExecutor required(Path root, String pattern) { 136 requiredResources 137 .add(Stream.of(FileTree.create(project, root, pattern))); 138 return this; 139 } 140 141 /// Convenience method to add a [FileResource] to the required resources. 142 /// If `path` is relative, it is resolved against the project's directory. 143 /// 144 /// @param root the root 145 /// @return the npm executor 146 /// 147 public NpmExecutor required(Path root) { 148 requiredResources.add(Stream.of(FileResource.create(project, root))); 149 return this; 150 } 151 152 /// Sets the function used to determine the resources provided by this 153 /// provider. 154 /// 155 /// @param resources the resources 156 /// @return the npm executor 157 /// 158 public NpmExecutor provided(Function<Project, Stream<Resource>> resources) { 159 this.getProvided = resources; 160 return this; 161 } 162 163 @Override 164 protected <T extends Resource> Stream<T> 165 doProvide(ResourceRequest<T> requested) { 166 if (requested.accepts(CleanlinessType)) { 167 getProvided.apply(project).forEach(Resource::cleanup); 168 return Stream.empty(); 169 } 170 if (!requested.accepts(ExecResultType) 171 || requested.name().map(n -> !n.equals(name())).orElse(false)) { 172 return Stream.empty(); 173 } 174 175 // Check prerequisites 176 File packageJson = project.directory().resolve("package.json").toFile(); 177 if (!packageJson.canRead()) { 178 throw new BuildException().from(this) 179 .message("No package.json in %s", project); 180 } 181 File dotPackageLock = project.directory() 182 .resolve("node_modules/.package-lock.json").toFile(); 183 if (!project.directory().resolve("node_modules").toFile().exists() 184 || !dotPackageLock.exists() 185 || packageJson.lastModified() > dotPackageLock.lastModified()) { 186 logger.atConfig().log("Updating node_modules in %s", project); 187 runNpm(project, List.of("install")); 188 } 189 190 // Make sure that the required resources exists 191 var required = newResource(new ResourceType<Resources<Resource>>() {}); 192 requiredResources.stream().forEach(required::addAll); 193 194 // Get (previously) provided and check if up-to-date 195 var provided = newResource(new ResourceType<Resources<Resource>>() {}); 196 provided.addAll(getProvided.apply(project)); 197 if (required.asOf().isPresent() && provided.asOf().isPresent() 198 && !required.asOf().get().isAfter(provided.asOf().get())) { 199 var execResult = newResource(ExecResultType, this, 200 "existing " + provided.stream().map(Resource::toString) 201 .collect(Collectors.joining(", ")), 202 0, provided.stream()); 203 @SuppressWarnings("unchecked") 204 var result = (Stream<T>) Stream.of(execResult); 205 return result; 206 207 } 208 209 return runNpm(project, arguments); 210 } 211 212 private <T extends Resource> Stream<T> runNpm( 213 Project project, List<String> arguments) { 214 List<String> command = new ArrayList<>(List.of("npm")); 215 command.addAll(arguments); 216 ProcessBuilder processBuilder = new ProcessBuilder(command) 217 .directory(project.directory().toFile()) 218 .redirectInput(Redirect.INHERIT); 219 try { 220 Process process = processBuilder.start(); 221 copyData(process.getInputStream(), context().out()); 222 copyData(process.getErrorStream(), context().error()); 223 @SuppressWarnings("unchecked") 224 var result = (Stream<T>) Stream.of(newResource(ExecResultType, this, 225 "[" + project.name() + "]$ " 226 + command.stream().collect(Collectors.joining(" ")), 227 process.waitFor(), getProvided.apply(project))); 228 return result; 229 } catch (IOException | InterruptedException e) { 230 throw new BuildException().from(this).cause(e); 231 } 232 } 233 234 private void copyData(InputStream source, OutputStream sink) { 235 Thread.startVirtualThread(() -> { 236 try (source) { 237 source.transferTo(sink); 238 } catch (IOException e) { // NOPMD 239 } 240 }); 241 } 242}