experiment: cache NPM dependencies via Gradle
diff --git a/build.gradle.kts b/build.gradle.kts
index 0870e26..293e9ad 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -640,6 +640,22 @@
             }
         }
 
+        // must be first, otherwise Gradle tries to fetch POM files from the cache-redirector repos,
+        // which fails because cache-director doesn't support HEAD requests.
+        ivy("https://registry.npmjs.org/") {
+            name = "npm repo for gradle-node-plugin"
+            patternLayout {
+                artifact("[orgPath]/-/[module]-[revision].[ext]")
+//                artifact("[orgPath]/-/[module]-[revision].[ext]")
+            }
+            metadataSources {
+                artifact()
+            }
+            content {
+                onlyForAttribute(Usage.USAGE_ATTRIBUTE, objects.named("npm-dependencies-cache"))
+            }
+        }
+
         mirrorRepo?.let(::maven)
 
         maven(intellijRepo) {
@@ -688,6 +704,29 @@
             }
         }
 
+//        exclusiveContent {
+//            forRepository {
+////                ivy("https://packages.jetbrains.team/") { // packages.jetbrains.team doesn't support HEAD?
+//                ivy("https://registry.npmjs.org/") {
+//                    name = "npm repo for gradle-node-plugin"
+//                    patternLayout {
+//                        // https://packages.jetbrains.team/npm/p/kt/kotlin-dependencies/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.0.tgz
+//                        artifact("[orgPath]/-/@[module]-[revision].[ext]")
+//                        artifact("[orgPath]/-/[module]-[revision].[ext]")
+//                    }
+//                    metadataSources {
+//                        artifact()
+//                    }
+////                    content {
+////                        onlyForAttribute(Usage.USAGE_ATTRIBUTE, objects.named("npm-dependencies-cache"))
+////                    }
+//                }
+//            }
+//            filter {
+//                includeGroupAndSubgroups("npm.p.kt.kotlin-dependencies")
+//            }
+//        }
+
         mavenCentral()
     }
 }
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index cdf0373..75dffb4 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -16,6 +16,151 @@
       </trusted-artifacts>
    </configuration>
    <components>
+      <component group="@jridgewell.gen-mapping" name="gen-mapping" version="0.3.8">
+         <artifact name="gen-mapping-0.3.8.tgz">
+            <sha256 value="eea6fe4688f23b4f006fddac83e5d5cfbf9235795f1e0027776ca1cb6f5b2248" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@jridgewell.resolve-uri" name="resolve-uri" version="3.1.2">
+         <artifact name="resolve-uri-3.1.2.tgz">
+            <sha256 value="db52f9f62558baab13353dd39e3200750fc33e6a129a575b46226f493776e230" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@jridgewell.set-array" name="set-array" version="1.2.1">
+         <artifact name="set-array-1.2.1.tgz">
+            <sha256 value="6fa2237de1ac707fb3e93152272ca5478319db7f97ed301821963a3780bf0648" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@jridgewell.source-map" name="source-map" version="0.3.6">
+         <artifact name="source-map-0.3.6.tgz">
+            <sha256 value="359aa9f6efb7c78e9bc06ec2ee2c0e60f35130c0265a24631c2fd2e9ff40d61c" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@jridgewell.sourcemap-codec" name="sourcemap-codec" version="1.5.0">
+         <artifact name="sourcemap-codec-1.5.0.tgz">
+            <sha256 value="bb27e266358b0ab7ab8a776d875d195c57ae9f24224045716dce2b51205f1b5d" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@jridgewell.trace-mapping" name="trace-mapping" version="0.3.25">
+         <artifact name="trace-mapping-0.3.25.tgz">
+            <sha256 value="fff09d294a0cffa226f658d6b7c2ca897b57b80f54cc6eeac545ab11574241ed" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.plugin-terser" name="plugin-terser" version="0.4.4">
+         <artifact name="plugin-terser-0.4.4.tgz">
+            <sha256 value="087fc82de5e7c904508ecf5f2c8c24f3df2af501859ceaf1b789f3c7c26b32c9" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-android-arm-eabi" name="rollup-android-arm-eabi" version="4.40.0">
+         <artifact name="rollup-android-arm-eabi-4.40.0.tgz">
+            <sha256 value="f4be4affb12c1a76b735055d6cd0b8bae056fefe0baa9396a0fae7508a50b2c6" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-android-arm64" name="rollup-android-arm64" version="4.40.0">
+         <artifact name="rollup-android-arm64-4.40.0.tgz">
+            <sha256 value="b026b8e1e53611f4de4bc0d94ae4d01ae48e0abf5af40170831e2cb6605a5f88" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-darwin-arm64" name="rollup-darwin-arm64" version="4.40.0">
+         <artifact name="rollup-darwin-arm64-4.40.0.tgz">
+            <sha256 value="5cbdfd1c6596c0e8e7d9272bbeabd6f4588e77ab96f72d92d45ca16ebb03cfd3" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-darwin-x64" name="rollup-darwin-x64" version="4.40.0">
+         <artifact name="rollup-darwin-x64-4.40.0.tgz">
+            <sha256 value="efdf02ffe85356a0ca3bb05b141a6d63d92146554fd8539322bae288487558bb" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-freebsd-arm64" name="rollup-freebsd-arm64" version="4.40.0">
+         <artifact name="rollup-freebsd-arm64-4.40.0.tgz">
+            <sha256 value="06f33247a20c3f85eb9f45f8f36031f527196d3d87f3ca7b68aa4e406ecdf7fb" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-freebsd-x64" name="rollup-freebsd-x64" version="4.40.0">
+         <artifact name="rollup-freebsd-x64-4.40.0.tgz">
+            <sha256 value="8e9c6a7e0b2dbd7f90f2bfa17dcb8e2fd2f97784ba1010dcc45f8687febf9c16" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-linux-arm-gnueabihf" name="rollup-linux-arm-gnueabihf" version="4.40.0">
+         <artifact name="rollup-linux-arm-gnueabihf-4.40.0.tgz">
+            <sha256 value="710f04a8c8b68d16758b1a21e0fd282eb064f9be2aea1b03f33a20666b286c11" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-linux-arm-musleabihf" name="rollup-linux-arm-musleabihf" version="4.40.0">
+         <artifact name="rollup-linux-arm-musleabihf-4.40.0.tgz">
+            <sha256 value="df31fd3b6bb227dc88f079cc3fdca737cb66dab8410e07ad58446b220766c3d3" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-linux-arm64-gnu" name="rollup-linux-arm64-gnu" version="4.40.0">
+         <artifact name="rollup-linux-arm64-gnu-4.40.0.tgz">
+            <sha256 value="504f689935d9778fad27e900930623ec7739b5e8c1d6cf71e3c6e8cfdb30bfa4" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-linux-arm64-musl" name="rollup-linux-arm64-musl" version="4.40.0">
+         <artifact name="rollup-linux-arm64-musl-4.40.0.tgz">
+            <sha256 value="1bd1153b2abf5beefba34284163313688c967472ef7129e588c7da4376cfcd9f" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-linux-loongarch64-gnu" name="rollup-linux-loongarch64-gnu" version="4.40.0">
+         <artifact name="rollup-linux-loongarch64-gnu-4.40.0.tgz">
+            <sha256 value="f39fd6397de933a66fbeea811a2f1c1313dad52197bba0c322768091d0ea68be" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-linux-powerpc64le-gnu" name="rollup-linux-powerpc64le-gnu" version="4.40.0">
+         <artifact name="rollup-linux-powerpc64le-gnu-4.40.0.tgz">
+            <sha256 value="5dacd37c50adcd5a84562980a5f1d13b7413f6817880803357dc93ef6f220421" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-linux-riscv64-gnu" name="rollup-linux-riscv64-gnu" version="4.40.0">
+         <artifact name="rollup-linux-riscv64-gnu-4.40.0.tgz">
+            <sha256 value="fe5c584fb75e6bda39bceca0c8616dc0f4237fa760572a4c5f23920d1942d5e7" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-linux-riscv64-musl" name="rollup-linux-riscv64-musl" version="4.40.0">
+         <artifact name="rollup-linux-riscv64-musl-4.40.0.tgz">
+            <sha256 value="00f4edb0182a5f2c98c24e705cbae6def3bb2d6169a058b2dd5988d18edbdd64" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-linux-s390x-gnu" name="rollup-linux-s390x-gnu" version="4.40.0">
+         <artifact name="rollup-linux-s390x-gnu-4.40.0.tgz">
+            <sha256 value="0374203733f33e1175d9d700f1d70f701836e08b13e66295c14fb106ad2b3a69" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-linux-x64-gnu" name="rollup-linux-x64-gnu" version="4.40.0">
+         <artifact name="rollup-linux-x64-gnu-4.40.0.tgz">
+            <sha256 value="648d4bc67bf5dc888a0ddefc3f6dc7dbd2734a2143db38b66e75630b91cca8de" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-linux-x64-musl" name="rollup-linux-x64-musl" version="4.40.0">
+         <artifact name="rollup-linux-x64-musl-4.40.0.tgz">
+            <sha256 value="22fba885b11d18cb4dd978f86a30d9b4c47962b3fbcf80934c141917e3a606f2" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-win32-arm64-msvc" name="rollup-win32-arm64-msvc" version="4.40.0">
+         <artifact name="rollup-win32-arm64-msvc-4.40.0.tgz">
+            <sha256 value="bc608baa6db4b39dac3d7ed55c93d31219ad05435a2e8c493ce71dc243797625" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-win32-ia32-msvc" name="rollup-win32-ia32-msvc" version="4.40.0">
+         <artifact name="rollup-win32-ia32-msvc-4.40.0.tgz">
+            <sha256 value="102f181ef5436266e93f7df9ae517186fca0d530b294533b457a01634cb08f63" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@rollup.rollup-win32-x64-msvc" name="rollup-win32-x64-msvc" version="4.40.0">
+         <artifact name="rollup-win32-x64-msvc-4.40.0.tgz">
+            <sha256 value="17363f6f7477d140956e12b7ab6d7dff91f051eb9ed38e959b0b389750762d56" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="@types.estree" name="estree" version="1.0.7">
+         <artifact name="estree-1.0.7.tgz">
+            <sha256 value="e6a77ad40eed5771cc43b2181cbeb9fd49e80a1bed6db0396778d587978e4d70" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="acorn" name="acorn" version="8.14.1">
+         <artifact name="acorn-8.14.1.tgz">
+            <sha256 value="e9ca7f9939683a07d74565636ab8cc29f5fbc886a3e7a54af22c2f9e0f59930f" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
       <component group="androidx.annotation" name="annotation" version="1.0.0">
          <artifact name="annotation-1.0.0.jar">
             <md5 value="558104d32e109ad96655ecbe9fe4e39f" origin="Generated by Gradle"/>
@@ -160,6 +305,11 @@
             <sha256 value="f5759b7fcdfc83a525a036deedcbd32e5b536b625ebc282426f16ca137eb5902" origin="Generated by Gradle"/>
          </artifact>
       </component>
+      <component group="buffer-from" name="buffer-from" version="1.1.2">
+         <artifact name="buffer-from-1.1.2.tgz">
+            <sha256 value="9c2b03d59eca8f463a1927e07273ddaa87785fe3f61626c42b005540e962e343" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
       <component group="ch.qos.logback" name="logback-classic" version="1.3.15">
          <artifact name="logback-classic-1.3.15.jar">
             <md5 value="6fae5bf09da5961706c45ac35d72e995" origin="Generated by Gradle"/>
@@ -1678,6 +1828,11 @@
             <sha256 value="f76b85adb0c00721ae267b7cfde4da7f71d3121cc2160c9fc00c0c89f8c53c8a" origin="Generated by Gradle"/>
          </artifact>
       </component>
+      <component group="commander" name="commander" version="2.20.3">
+         <artifact name="commander-2.20.3.tgz">
+            <sha256 value="3c02903de017a98d4dc3fe2bed4900beb0d04cf7b679dca0915fd8c759652b55" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
       <component group="commons-beanutils" name="commons-beanutils" version="1.10.1">
          <artifact name="commons-beanutils-1.10.1.jar">
             <md5 value="27ac839d60e2bff6b222827756fde6cb" origin="Generated by Gradle"/>
@@ -1786,6 +1941,11 @@
             <sha256 value="d2720b0feb36272c7d311003a14d77ec108603c38c301329791f6ceedb6396f5" origin="Generated by Gradle"/>
          </artifact>
       </component>
+      <component group="fsevents" name="fsevents" version="2.3.3">
+         <artifact name="fsevents-2.3.3.tgz">
+            <sha256 value="c77e7a5d5ff31dd7acea7c44d4a0455e0528cdacbd24a8cb6c82b66d239b587e" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
       <component group="google" name="91936d4ee3ccc839f0addd53c9ebf087b1e39251.build-tools" version="r30.0.3">
          <artifact name="91936d4ee3ccc839f0addd53c9ebf087b1e39251.build-tools-r30.0.3-windows.zip">
             <md5 value="f0b89c50696fe827a462080af164f1ef" origin="Generated by Gradle"/>
@@ -5816,6 +5976,46 @@
             <sha256 value="1be1aac16d424ce940d5407bef86656dc4ed5803c93e563cb1682ae07b591ecb" origin="Generated by Gradle"/>
          </artifact>
       </component>
+      <component group="randombytes" name="randombytes" version="2.1.0">
+         <artifact name="randombytes-2.1.0.tgz">
+            <sha256 value="b8a6d3e6532817912ad3dacbb0e64be75026941a5167549aa1d7fecbafd1bcaa" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="rollup" name="rollup" version="4.40.0">
+         <artifact name="rollup-4.40.0.tgz">
+            <sha256 value="c25de66498beddeb3dec3fb9511b88c79b2b9e53c37763a12f46ce2b46387580" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="safe-buffer" name="safe-buffer" version="5.2.1">
+         <artifact name="safe-buffer-5.2.1.tgz">
+            <sha256 value="5d181804516c4a693a384272a7bd0e42d17e0d4b301ccfbe408669ccafdcb3e8" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="serialize-javascript" name="serialize-javascript" version="6.0.2">
+         <artifact name="serialize-javascript-6.0.2.tgz">
+            <sha256 value="2f292f35c62f85997a1e1e21473406b512b440609cfd359d7a26995c1b431ae2" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="smob" name="smob" version="1.5.0">
+         <artifact name="smob-1.5.0.tgz">
+            <sha256 value="85b6c338a99092849907520ee9e027c3710ee3dbbb94e240e5d3bec0a6f8d4aa" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="source-map" name="source-map" version="0.6.1">
+         <artifact name="source-map-0.6.1.tgz">
+            <sha256 value="bdbca10d17ff5a5802d5acfc7b2f22f9f9bf587632a95650d3c5f513c7092b86" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="source-map-support" name="source-map-support" version="0.5.21">
+         <artifact name="source-map-support-0.5.21.tgz">
+            <sha256 value="5d9b04ef3e6824fdcf91cfcc03ab427fae486bc6859735805593f51b3554f636" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
+      <component group="terser" name="terser" version="5.39.0">
+         <artifact name="terser-5.39.0.tgz">
+            <sha256 value="ef669abcd38fc0a31dede049f1186ae4999783c34cfa6652a5e12b4e57cccf58" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
       <component group="webassembly" name="testsuite" version="18f8340">
          <artifact name="testsuite-18f8340.zip">
             <md5 value="d6ea2de35c54be9e8cfd74eebe475b62" origin="Generated by Gradle"/>
diff --git a/repo/gradle-settings-conventions/cache-redirector/src/main/kotlin/cache-redirector.settings.gradle.kts b/repo/gradle-settings-conventions/cache-redirector/src/main/kotlin/cache-redirector.settings.gradle.kts
index 448dbca2..138e59b 100644
--- a/repo/gradle-settings-conventions/cache-redirector/src/main/kotlin/cache-redirector.settings.gradle.kts
+++ b/repo/gradle-settings-conventions/cache-redirector/src/main/kotlin/cache-redirector.settings.gradle.kts
@@ -153,7 +153,8 @@
     "https://jcenter.bintray.com" to "https://cache-redirector.jetbrains.com/jcenter.bintray.com",
     "https://jcenter.bintray.com" to "https://cache-redirector.jetbrains.com/jcenter",
     "https://www.python.org/ftp" to "https://cache-redirector.jetbrains.com/www.python.org/ftp",
-    "https://registry.npmjs.org" to "https://cache-redirector.jetbrains.com/registry.npmjs.org",
+    // cache-redirector.jetbrains doesn't support HEAD requests :(
+    //"https://registry.npmjs.org" to "https://cache-redirector.jetbrains.com/registry.npmjs.org",
     "https://maven.google.com" to "https://cache-redirector.jetbrains.com/maven.google.com",
     "https://dl.google.com/go" to "https://cache-redirector.jetbrains.com/dl.google.com/go",
     "https://dl.google.com/go" to "https://cache-redirector.jetbrains.com/dl.google.com.go",
diff --git a/wasm/wasm.debug.browsers/build.gradle.kts b/wasm/wasm.debug.browsers/build.gradle.kts
index fe00a52..00d8e68 100644
--- a/wasm/wasm.debug.browsers/build.gradle.kts
+++ b/wasm/wasm.debug.browsers/build.gradle.kts
@@ -1,10 +1,19 @@
+import com.github.gradle.node.NodeExtension
+import com.github.gradle.node.exec.NodeExecConfiguration
+import com.github.gradle.node.npm.exec.NpmExecRunner
 import com.github.gradle.node.npm.task.NpxTask
+import com.github.gradle.node.util.DefaultProjectApiHelper
+import com.github.gradle.node.variant.VariantComputer
+import groovy.json.JsonSlurper
 import org.gradle.api.tasks.PathSensitivity.RELATIVE
+import org.gradle.kotlin.dsl.support.serviceOf
+import java.io.ByteArrayOutputStream
+import java.io.Serializable
 
 description = "Simple Kotlin/Wasm devtools formatters"
 
 plugins {
-    id("base")
+    base
     id("share-kotlin-wasm-custom-formatters")
     alias(libs.plugins.gradle.node)
 }
@@ -39,6 +48,7 @@
     group = "build"
 
     dependsOn(tasks.npmInstall)
+    dependsOn(addNodeModulesToNpmCache)
 
     val rollupConfigMjsFile = file("rollup.config.mjs")
     inputs.file(rollupConfigMjsFile)
@@ -61,10 +71,10 @@
 
 tasks {
     npmInstall {
-        val nodeModulesDir = projectDir.resolve("node_modules")
-        outputs.upToDateWhen {
-            nodeModulesDir.isDirectory
-        }
+//        val nodeModulesDir = projectDir.resolve("node_modules")
+//        outputs.upToDateWhen {
+//            nodeModulesDir.isDirectory
+//        }
 
         if (gradle.startParameter.isOffline) {
             args.add("--offline")
@@ -83,3 +93,283 @@
         artifact(npmBuild)
     }
 }
+
+
+/**
+ * Extract node_modules dependencies from the `package-lock.json`.
+ *
+ * Convert each to GAV dependency coordinates.
+ */
+val nodeModuleDependencies = try {
+    val packageLockFile = file("package-lock.json")
+    JsonSlurper().parse(packageLockFile)
+        .let { it as Map<*, *> }
+        .let { it["packages"] as Map<*, *> }
+        .let { packages ->
+            packages.keys
+                .map { dep -> dep as String }
+                .filter { dep -> dep.startsWith("node_modules/") }
+                .map { dep ->
+                    val depData = packages[dep] as Map<*, *>
+                    val version = depData["version"] as String
+
+                    val module = dep.substringAfter("node_modules/").substringAfter("/")
+                    val group = dep.substringAfter("node_modules/")
+                        .replace("/", ".")
+                    //.removePrefix("@")
+
+                    //"npm.p.kt.kotlin-dependencies.$group:$module:$version@tgz"
+                    "$group:$module:$version@tgz"
+                }
+        }
+} catch (e: Exception) {
+    System.err.println("Error parsing package-lock.json")
+    throw e
+}
+
+val nodeModulesDependencies by configurations.registering {
+    isCanBeDeclared = true
+    isCanBeResolved = false
+    isCanBeConsumed = false
+    isVisible = false
+    defaultDependencies {
+        nodeModuleDependencies.forEach {
+            add(project.dependencies.create(it))
+        }
+    }
+}
+
+val nodeModulesDependenciesResolver by configurations.registering {
+    isCanBeDeclared = false
+    isCanBeResolved = true
+    isCanBeConsumed = false
+    isVisible = false
+    extendsFrom(nodeModulesDependencies.get())
+    attributes {
+        attribute(Usage.USAGE_ATTRIBUTE, project.objects.named("npm-dependencies-cache"))
+    }
+}
+
+// use an object to avoid 'project script references' CC errors
+object NpmUtil {
+
+    /**
+     * Util for executing npm.
+     */
+    fun npmExecProvider(
+        nodeExtension: NodeExtension,
+        objects: ObjectFactory,
+        configure: NpmExecProviderSpec.() -> Unit,
+    ): Provider<NpmExecResult> {
+
+        val spec = objects.newInstance<NpmExecProviderSpec>()
+            .apply(configure)
+
+        return spec.providers.provider {
+            val variantComputer = VariantComputer()
+            val projectHelper = objects.newInstance<DefaultProjectApiHelper>()
+            val npmExecRunner = objects.newInstance<NpmExecRunner>()
+
+            val stdOut = ByteArrayOutputStream()
+            val stdErr = ByteArrayOutputStream()
+
+            val result = try {
+                val nodeExecConfiguration =
+                    NodeExecConfiguration(
+                        command = spec.command.get(),
+                        environment = spec.environment.orNull.orEmpty(),
+                        workingDir = nodeExtension.nodeProjectDir.get().asFile,
+                        ignoreExitValue = spec.ignoreExitValue.orNull ?: false,
+                    ) {
+                        standardOutput = stdOut
+                        errorOutput = stdErr
+                    }
+
+                npmExecRunner.executeNpmCommand(
+                    project = projectHelper,
+                    extension = nodeExtension,
+                    nodeExecConfiguration = nodeExecConfiguration,
+                    variants = variantComputer,
+                )
+            } finally {
+                stdOut.close()
+                stdErr.close()
+            }
+
+            NpmExecResult(
+                exitValue = result.exitValue,
+                standardOutput = stdOut.toString(),
+                errorOutput = stdErr.toString(),
+            )
+        }
+    }
+
+    abstract class NpmExecProviderSpec @Inject constructor(
+        val providers: ProviderFactory,
+    ) {
+        abstract val command: ListProperty<String>
+        abstract val environment: MapProperty<String, String>
+        abstract val ignoreExitValue: Property<Boolean>
+
+        /** Set the value of the [command] property. */
+        fun command(vararg command: String) {
+            this.command.set(listOf(*command))
+        }
+    }
+
+    data class NpmExecResult(
+        val exitValue: Int,
+        val standardOutput: String,
+        val errorOutput: String,
+    ) : Serializable
+
+}
+
+val addNodeModulesToNpmCache by tasks.registering {
+    val objects = serviceOf<ObjectFactory>()
+
+    val nodeModulesDependenciesFiles = nodeModulesDependenciesResolver.map { it.incoming.files }
+    inputs.files(nodeModulesDependenciesFiles)
+
+    dependsOn(tasks.nodeSetup)
+
+    val nodeExtension = project.node
+
+    doLast {
+
+        /**
+         * Get all content of npm's cache, so we can avoid re-adding items to the cache (which is a little slow).
+         */
+        val cacheLsLines = run {
+            val npmCacheLsResult = NpmUtil.npmExecProvider(nodeExtension, objects) {
+                command("cache", "ls")
+            }.get()
+
+            logger.info("npm cache ls result :\n${npmCacheLsResult.toString().lines().joinToString("\\n ")}")
+
+            npmCacheLsResult.standardOutput.lineSequence()
+        }
+
+        nodeModulesDependenciesFiles.get()
+            .filter { dep ->
+                // filter out dependencies that are already present in npm's cache
+                cacheLsLines.none { it.endsWith(dep.invariantSeparatorsPath) }
+            }
+            .forEach { dep ->
+                logger.lifecycle("Adding ${dep.name} to npm cache...")
+
+                val result = NpmUtil.npmExecProvider(nodeExtension, objects) {
+                    command("cache", "add", dep.invariantSeparatorsPath)
+                }.get()
+
+                logger.info("npm cache add result: ${result.toString().lines().joinToString("\\n ")}")
+            }
+    }
+}
+
+// region EXPERIMENT - run `npm install --dry-run --offline` as an up-to-date check for npmInstall
+// https://github.com/node-gradle/gradle-node-plugin/issues/81
+
+
+//    abstract class NpmExecSource @Inject internal constructor(
+//        private val execOps: ExecOperations,
+//    ) : ValueSource<NpmExecResult, NpmExecSource.Parameters> {
+//
+//        interface Parameters : ValueSourceParameters {
+//            //        val nodeExtension: Property<NodeExtension>
+//            val environment: MapProperty<String, String>
+//            val ignoreExitValue: Property<Boolean>
+//            val command: ListProperty<String>
+//            val
+////        val workingDir: DirectoryProperty
+//        }
+//
+//        override fun obtain(): NpmExecResult {
+//            val variantComputer = VariantComputer()
+//            val projectHelper = objects.newInstance(DefaultProjectApiHelper::class)
+//            val npmExecRunner = objects.newInstance(NpmExecRunner::class)
+//
+//            val stdOut = ByteArrayOutputStream()
+//            val stdErr = ByteArrayOutputStream()
+//
+//            val nodeExtension = NodeExtension(ProjectBuilder.builder().build())
+//
+//            val result = try {
+//                val nodeExecConfiguration =
+//                    NodeExecConfiguration(
+//                        command = parameters.command.get(),
+//                        environment = parameters.environment.orNull.orEmpty(),
+////                    workingDir = parameters.nodeExtension.get().nodeProjectDir.get().asFile,
+//                        workingDir = nodeExtension.nodeProjectDir.get().asFile,
+//                        ignoreExitValue = parameters.ignoreExitValue.orNull ?: false,
+//                    ) {
+//                        standardOutput = stdOut
+//                        errorOutput = stdErr
+//                    }
+//
+//                npmExecRunner.executeNpmCommand(
+//                    project = projectHelper,
+////                extension = parameters.nodeExtension.get(),
+//                    extension =  nodeExtension ,
+//                    nodeExecConfiguration = nodeExecConfiguration,
+//                    variants = variantComputer,
+//                )
+//            } finally {
+//                stdOut.close()
+//                stdErr.close()
+//            }
+//
+//            return NpmExecResult(
+//                result.exitValue,
+//                standardOutput = stdOut.toString(),
+//                errorOutput = stdErr.toString(),
+//            )
+//        }
+//    }
+
+//val npmInstallUpToDateCheck by tasks.registering {
+//    val nodeExtension = project.node
+//    val objects = serviceOf<ObjectFactory>()
+//    val npmInstallOfflineExecResult = NpmUtil.npmExecProvider(nodeExtension, objects) {
+//        command = listOf("install", "--dry-run", "--offline")
+//    }
+//
+//    val result = temporaryDir.resolve("result.txt")
+//    outputs.file(result)
+//
+//    doLast {
+//        println("[$path] checking if npm install is up to date...")
+//        val isUpToDate: Boolean =
+//            npmInstallOfflineExecResult.get().run {
+//                println(npmInstallOfflineExecResult)
+//                errorOutput.isEmpty()
+//                        && standardOutput.lines().none { it.startsWith("add") }
+//            }
+//        println("[$path] result : $isUpToDate")
+//        result.apply {
+//            parentFile.mkdirs()
+//            createNewFile()
+//            writeText(isUpToDate.toString())
+//        }
+//    }
+//}
+////
+//tasks.npmInstall {
+////    val nodeExtension = project.node
+////    val objects = serviceOf<ObjectFactory>()
+//    val npmInstallOfflineExecResult = nodeExtension.npmExec {
+//        command = listOf("install", "--dry-run", "--offline")
+//    }
+//
+////    val upToDateCheckResult = npmInstallUpToDateCheck.map { it.outputs.files.singleOrNull()?.readText()?.toBoolean() ?: false }
+//
+//    outputs.upToDateWhen {
+//        "missing dependencies" in npmInstallOfflineExecResult.get().standardOutput
+////        npmInstallOfflineExecResult.get().run {
+////            println(npmInstallOfflineExecResult)
+////            errorOutput.isEmpty()
+////                    && standardOutput.lines().none { it.startsWith("add") }
+////        }
+//    }
+//}
+//endregion