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 java.io.IOException;
022import java.io.InputStream;
023import java.net.URI;
024import java.net.http.HttpClient;
025import java.net.http.HttpRequest;
026import java.net.http.HttpResponse.BodyHandlers;
027import java.nio.file.Files;
028import java.nio.file.Path;
029import java.nio.file.Paths;
030import java.nio.file.StandardCopyOption;
031import java.util.Locale;
032import java.util.zip.ZipEntry;
033import java.util.zip.ZipInputStream;
034import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
035import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
036import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
037import org.jdrupes.builder.api.BuildException;
038import org.jdrupes.builder.api.ResourceProvider;
039
040/// Manages cached node js downloads.
041///
042public class NodeJsDownloader {
043
044    private final ResourceProvider provider;
045    private final Path baseDir;
046    private final Platform platform;
047
048    /// Initializes a new node js downloader.
049    ///
050    /// @param provider the provider
051    /// @param baseDir the base dir
052    ///
053    public NodeJsDownloader(ResourceProvider provider, Path baseDir) {
054        this.provider = provider;
055        this.baseDir = baseDir;
056        platform = detectPlatform();
057    }
058
059    private Platform detectPlatform() {
060        String opSys = System.getProperty("os.name").toLowerCase(Locale.ROOT);
061        String arch
062            = System.getProperty("os.arch").contains("64") ? "x64" : "x86";
063
064        if (opSys.contains("win")) {
065            return new Platform("win-" + arch, true, "zip");
066        } else if (opSys.contains("mac")) {
067            return new Platform("darwin-" + arch, false, "tar.gz");
068        } else if (opSys.contains("nux")) {
069            return new Platform("linux-" + arch, false, "tar.gz");
070        }
071        throw new BuildException().from(provider)
072            .message("Unsupported OS: %s", opSys);
073    }
074
075    /// Returns the path to the npm executable for the given version.
076    ///
077    /// @param version the version
078    /// @return the path
079    ///
080    public Path npmExecutable(String version) {
081        Path installDir = downloadAndInstall(version);
082        return executable(installDir);
083    }
084
085    private Path executable(Path installDir) {
086        if (platform.isWindows) {
087            return installDir.resolve("npm.cmd");
088        } else {
089            return installDir.resolve("bin/npm");
090        }
091    }
092
093    private Path downloadAndInstall(String version) {
094        Path unpackedIn = baseDir
095            .resolve(String.format("node-v%s-%s", version, platform.name));
096        if (Files.exists(unpackedIn)) {
097            return unpackedIn;
098        }
099        try {
100            Files.createDirectories(baseDir);
101            var archive = download(baseDir, version);
102            if (archive.toString().endsWith(".zip")) {
103                unzip(baseDir, archive);
104            } else if (archive.toString().endsWith(".tar.gz")) {
105                untarGz(baseDir, archive);
106            }
107            executable(unpackedIn).toFile().setExecutable(true, false);
108            Files.deleteIfExists(archive);
109            return unpackedIn;
110        } catch (IOException | InterruptedException e) {
111            throw new BuildException().from(provider).cause(e);
112        }
113    }
114
115    @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
116    private Path download(Path targetDir, String version)
117            throws IOException, InterruptedException {
118        try (var client = HttpClient.newHttpClient()) {
119            String archiveName = String.format("node-v%s-%s.%s", version,
120                platform.name, platform.extension);
121            String downloadUrl = String.format("https://nodejs.org/dist/v%s/%s",
122                version, archiveName);
123            var request = HttpRequest.newBuilder().GET()
124                .uri(URI.create(downloadUrl)).build();
125            var target = targetDir.resolve(archiveName);
126            var response = client.send(request, BodyHandlers.ofFile(target));
127            if (response.statusCode() / 100 != 2) {
128                throw new BuildException().from(provider).message(
129                    "Attempt to download %s failed with %d", downloadUrl,
130                    response.statusCode());
131            }
132            return target;
133        }
134    }
135
136    private void unzip(Path targetDir, Path zipFile) throws IOException {
137        try (ZipInputStream zis
138            = new ZipInputStream(Files.newInputStream(zipFile))) {
139            while (true) {
140                ZipEntry entry = zis.getNextEntry();
141                if (entry == null) {
142                    break;
143                }
144                Path newPath = targetDir.resolve(entry.getName());
145                if (entry.isDirectory()) {
146                    Files.createDirectories(newPath);
147                } else {
148                    Files.createDirectories(newPath.getParent());
149                    Files.copy(zis, newPath,
150                        StandardCopyOption.REPLACE_EXISTING);
151                }
152            }
153        }
154    }
155
156    private void untarGz(Path targetDir, Path tarGz) throws IOException {
157        try (InputStream tarGzIn = Files.newInputStream(tarGz);
158                InputStream tarIn = new GzipCompressorInputStream(tarGzIn);
159                TarArchiveInputStream tar = new TarArchiveInputStream(tarIn)) {
160
161            while (true) {
162                TarArchiveEntry entry = tar.getNextEntry();
163                if (entry == null) {
164                    break;
165                }
166                Path newPath = targetDir.resolve(entry.getName());
167                if (entry.isDirectory()) {
168                    Files.createDirectories(newPath);
169                } else if (entry.isSymbolicLink()) {
170                    Files.createDirectories(newPath.getParent());
171                    Path target = Paths.get(entry.getLinkName());
172                    Files.createSymbolicLink(newPath, target);
173                } else {
174                    Files.createDirectories(newPath.getParent());
175                    Files.copy(tar, newPath,
176                        StandardCopyOption.REPLACE_EXISTING);
177                }
178            }
179        }
180    }
181
182    private record Platform(String name, boolean isWindows,
183            String extension) {
184    }
185
186}