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.time.Instant;
029import java.util.ArrayList;
030import java.util.Arrays;
031import java.util.Collection;
032import java.util.Collections;
033import java.util.List;
034import java.util.Objects;
035import java.util.function.Function;
036import java.util.stream.Collectors;
037import java.util.stream.Stream;
038import org.jdrupes.builder.api.BuildException;
039import org.jdrupes.builder.api.Cleanliness;
040import org.jdrupes.builder.api.ExecResult;
041import org.jdrupes.builder.api.FileResource;
042import org.jdrupes.builder.api.FileTree;
043import org.jdrupes.builder.api.Project;
044import org.jdrupes.builder.api.Renamable;
045import org.jdrupes.builder.api.RequiredResourceSupport;
046import org.jdrupes.builder.api.Resource;
047import org.jdrupes.builder.api.ResourceProvider;
048import org.jdrupes.builder.api.ResourceRequest;
049import org.jdrupes.builder.api.ResourceType;
050import static org.jdrupes.builder.api.ResourceType.*;
051import org.jdrupes.builder.api.Resources;
052import org.jdrupes.builder.core.AbstractProvider;
053import org.jdrupes.builder.core.StreamCollector;
054
055/// A provider for [execution results][ExecResult] from invoking npm.
056/// The provider generates resources in response to requests for
057/// [ExecResult] where the request's [ResourceRequest#name()] matches
058/// this [provider's name][ResourceProvider#name()].
059/// 
060///   * The working directory is the project directory.
061/// 
062///   * The provider first checks if a file `package.json` exists, else it
063///     fails. If no directory `node_modules` exists or `package.json`
064///     is newer than `node_modules/.package-lock.json` it invokes `npm init`.
065/// 
066///   * Then, the provider retrieves all resources added by [#required]. While
067///     the provider itself does not process these resources, it is assumed
068///     that they are processed by the `npm` command and therefore need to be
069///     available.
070/// 
071///   * If no arguments were specified, the provider returns an [ExecResult]
072///     that indicates successful invocation. The date of the result is set
073///     to the date of `node_modules/.package-lock.json`.
074/// 
075///   * The provider invokes the function configured with [#output] and
076///     collects all resources. If the generated resources exist and no
077///     resource from `required` is newer then the generated resources found,
078///     the provider returns a result that indicates successful invocation.
079///     The date of the result is set to the newest date from the generated
080///     resources and the (existing) resources are attached.  
081/// 
082///   * Else, the provider invokes npm, calls the function set with
083///     `provided` again and adds the result to the [ExecResult] that
084///     it returns.
085///
086/// The provider also uses the function set with [#output] to determine
087/// the resources to be removed when it is invoked with a request for
088/// [Cleanliness].
089/// 
090/// The generated resources can also be provided directly in response
091/// to a request, see [#provideResources(ResourceRequest)].
092/// 
093/// This provider is made available as an extension.
094/// [![org.jdrupes:jdbld-ext-nodejs:](
095/// https://img.shields.io/maven-central/v/org.jdrupes/jdbld-ext-nodejs?label=org.jdrupes:jdbld-ext-nodejs%3A)
096/// ](https://mvnrepository.com/artifact/org.jdrupes/jdbld-ext-nodejs)
097///
098public class NpmExecutor extends AbstractProvider
099        implements Renamable, RequiredResourceSupport {
100
101    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
102    private final Project project;
103    private final List<String> arguments = new ArrayList<>();
104    private final StreamCollector<Resource> requiredResources
105        = new StreamCollector<>(false);
106    private Function<Project, Stream<Resource>> getOutput
107        = _ -> Stream.empty();
108    private String nodeJsVersion;
109    private NodeJsDownloader nodeJsDownloader;
110    private ResourceRequest<?> requestForGenerated;
111
112    /// Initializes a new NPM executor.
113    ///
114    /// @param project the project
115    ///
116    public NpmExecutor(Project project) {
117        this.project = project;
118        rename(NpmExecutor.class.getSimpleName() + " in " + project);
119    }
120
121    /// Returns the project that this provider belongs to.
122    ///
123    /// @return the project
124    ///
125    public Project project() {
126        return project;
127    }
128
129    /// Name.
130    ///
131    /// @param name the name
132    /// @return the npm executor
133    ///
134    @Override
135    public NpmExecutor name(String name) {
136        rename(name);
137        return this;
138    }
139
140    /// Sets the node.js version to use. Setting a version is mandatory.
141    ///
142    /// @param version the version
143    /// @return the npm executor
144    ///
145    public NpmExecutor nodeJsVersion(String version) {
146        nodeJsVersion = version;
147        return this;
148    }
149
150    /// Add the given arguments.
151    ///
152    /// @param args the arguments
153    /// @return the npm executor
154    ///
155    public NpmExecutor args(String... args) {
156        arguments.addAll(Arrays.asList(args));
157        return this;
158    }
159
160    @Override
161    public NpmExecutor required(Stream<? extends Resource> resources) {
162        requiredResources.add(resources);
163        return this;
164    }
165
166    @Override
167    public NpmExecutor required(Path root, String pattern) {
168        requiredResources
169            .add(Stream.of(FileTree.of(project, root, pattern)));
170        return this;
171    }
172
173    @Override
174    public NpmExecutor required(Path root) {
175        requiredResources.add(
176            Stream.of(FileResource.of(project.directory().resolve(root))));
177        return this;
178    }
179
180    /// Sets the function used to determine the resources generated by this
181    /// provider.
182    ///
183    /// @param resources the resources
184    /// @return the npm executor
185    ///
186    public NpmExecutor output(
187            Function<Project, Stream<Resource>> resources) {
188        this.getOutput = resources;
189        return this;
190    }
191
192    /// Provide the generated resources in response to a request like
193    /// the given one.
194    ///
195    /// @param proto defines the kind of request that the npm executor
196    /// should respond to with the generated resources
197    /// @return the npm executor
198    ///
199    public NpmExecutor provideResources(ResourceRequest<?> proto) {
200        requestForGenerated = proto;
201        return this;
202    }
203
204    @Override
205    @SuppressWarnings({ "PMD.CyclomaticComplexity" })
206    protected <T extends Resource> Collection<T>
207            doProvide(ResourceRequest<T> request) {
208        if (request.accepts(CleanlinessType)) {
209            getOutput.apply(project).forEach(Resource::cleanup);
210            return Collections.emptyList();
211        }
212
213        // Handle request for generated resources
214        if (requestForGenerated != null
215            && request.accepts(requestForGenerated.type())
216            && (requestForGenerated.name().isEmpty()
217                || Objects.equals(requestForGenerated.name().get(),
218                    request.name().orElse(null)))) {
219            // No need to evaluate for most special type because
220            // everything is derived from the exec result
221            return provideGenerated();
222        }
223
224        // Check for and handle request for execution result
225        if (!request.accepts(ExecResultType)
226            || !name().equals(request.name().orElse(null))) {
227            return Collections.emptyList();
228        }
229        // Always evaluate for the most special type
230        if (!request.type().equals(ExecResultType)) {
231            @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" })
232            var result = (Collection<T>) resources(of(ExecResultType)
233                .withName(name())).toList();
234            return result;
235        }
236
237        // Check prerequisites
238        if (nodeJsVersion == null) {
239            throw new BuildException().from(this)
240                .message("No node.js version specified");
241        }
242        nodeJsDownloader = new NodeJsDownloader(this, context()
243            .commonCacheDirectory().resolve(getClass().getPackageName()));
244        File packageJson = project.directory().resolve("package.json").toFile();
245        if (!packageJson.canRead()) {
246            throw new BuildException().from(this)
247                .message("No package.json in %s", project);
248        }
249        File dotPackageLock = project.directory()
250            .resolve("node_modules/.package-lock.json").toFile();
251        if (!project.directory().resolve("node_modules").toFile().exists()
252            || !dotPackageLock.exists()
253            || packageJson.lastModified() > dotPackageLock.lastModified()) {
254            logger.atConfig().log("Updating node_modules in %s", project);
255            runNpm(project, List.of("install"));
256        }
257
258        // Make sure that the required resources are retrieved and exist
259        var required = Resources.of(new ResourceType<Resources<Resource>>() {});
260        required.addAll(requiredResources.stream());
261        if (arguments.isEmpty()) {
262            @SuppressWarnings("unchecked")
263            var result = (T) ExecResult
264                .of(this, "npm install", 0, Stream.empty())
265                .asOf(Instant.ofEpochMilli(dotPackageLock.lastModified()));
266            return List.of(result);
267        }
268
269        // Get (previously) provided and check if up-to-date
270        var existing = Resources.of(new ResourceType<Resources<Resource>>() {});
271        existing.addAll(getOutput.apply(project));
272        if (required.asOf().isPresent() && existing.asOf().isPresent()
273            && !required.asOf().get().isAfter(existing.asOf().get())) {
274            logger.atFine().log("Output from %s is up to date", this);
275            @SuppressWarnings("unchecked")
276            var result = (T) ExecResult.of(this,
277                "existing " + existing.stream().map(Resource::toString)
278                    .collect(Collectors.joining(", ")),
279                0, existing.stream()).asOf(existing.asOf().get());
280            return List.of(result);
281        }
282        return runNpm(project, arguments);
283    }
284
285    private <T extends Resource> Collection<T> runNpm(
286            Project project, List<String> arguments) {
287        var nodeJsExecutable = nodeJsDownloader.npmExecutable(nodeJsVersion);
288        logger.atFine().log("Running %s with %s", this, nodeJsExecutable);
289        List<String> command
290            = new ArrayList<>(List.of(nodeJsExecutable.toString()));
291        command.addAll(arguments);
292        ProcessBuilder processBuilder = new ProcessBuilder(command)
293            .directory(project.directory().toFile())
294            .redirectInput(Redirect.INHERIT);
295        try {
296            Process process = processBuilder.start();
297            copyData(process.getInputStream(), context().out());
298            // npm uses stderr for progress information that we don't
299            // want to marked as error.
300            copyData(process.getErrorStream(), context().out());
301            int exitValue = process.waitFor();
302            if (exitValue != 0) {
303                throw new BuildException().from(this)
304                    .message("Npm exited with %d", exitValue);
305            }
306            @SuppressWarnings("unchecked")
307            var result = (Collection<T>) List.of(ExecResult.of(this,
308                "[" + project.name() + "]$ npm "
309                    + arguments.stream().collect(Collectors.joining(" ")),
310                exitValue, getOutput.apply(project))
311                .asOf(Instant.now()));
312            return result;
313        } catch (IOException | InterruptedException e) {
314            throw new BuildException().from(this).cause(e);
315        }
316    }
317
318    private void copyData(InputStream source, OutputStream sink) {
319        Thread.startVirtualThread(() -> {
320            try (source) {
321                source.transferTo(sink);
322            } catch (IOException e) { // NOPMD
323            }
324        });
325    }
326
327    private <T extends Resource> Collection<T> provideGenerated() {
328        // Request execution result
329        var execResult = resources(of(ExecResultType).withName(name()));
330        @SuppressWarnings("unchecked")
331
332        // Extract generated
333        var generated = execResult.map(r -> (Stream<T>) r.resources())
334            .flatMap(s -> s).toList();
335        return generated;
336    }
337
338    /// To string.
339    ///
340    /// @return the string
341    ///
342    @Override
343    public String toString() {
344        return super.toString() + "[" + project().name() + "]";
345    }
346}