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/// [![org.jdrupes:jdbld-ext-nodejs:](
055/// https://img.shields.io/maven-central/v/org.jdrupes/jdbld-ext-nodejs?label=org.jdrupes:jdbld-ext-nodejs%3A)
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}