Subject: [PATCH v3] import: Add binary npm importer.

From: [email-masked]
Date: Sun, 31 Mar 2024 22:37:01 +0200

From: Jelle Licht <[email-masked]>

* guix/scripts/import.scm: (importers): Add "npm-binary".
* guix/import/npm-binary.scm: New file.
* guix/scripts/import/npm-binary.scm: New file.
* Makefile.am: Add them.

Co-authored-by: Timothy Sample <[email-masked]>
Co-authored-by: Lars-Dominik Braun <[email-masked]>

Change-Id: I98a45068cf5b9c42790664cc743feaa7ac76f807
---

Changes in v3:
- Ensure that package bindings generated during recursive import match the
package bindings used in the list of inputs, as this was a regression introduced in the V2 series.

Changes in v2:
- Change *SOME-VAR* to %SOME-VAR
- Removed unused http-error-code
- Rebase on master
- Refactor hash-url to use port-sha256 helper
- use explicit record accessors instead of order-sensitive destructuring
- address line-width styling issues
- added basic documentation
- added some basic tests (using simple mocks instead of with-http-server)
- simplify import script entrypoint

 Makefile.am                        |   3 +
 doc/guix.texi                      |  33 ++++
 guix/import/npm-binary.scm         | 277 +++++++++++++++++++++++++++++
 guix/scripts/import.scm            |   2 +-
 guix/scripts/import/npm-binary.scm | 114 ++++++++++++
 tests/npm-binary.scm               | 146 +++++++++++++++
 6 files changed, 574 insertions(+), 1 deletion(-)
 create mode 100644 guix/import/npm-binary.scm
 create mode 100644 guix/scripts/import/npm-binary.scm
 create mode 100755 tests/npm-binary.scm

diff --git a/Makefile.am b/Makefile.am
index 1c5688ac13..459c47a954 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -305,6 +305,7 @@ MODULES =					\
   guix/import/kde.scm				\
   guix/import/launchpad.scm   			\
   guix/import/minetest.scm   			\
+  guix/import/npm-binary.scm			\
   guix/import/opam.scm				\
   guix/import/print.scm				\
   guix/import/pypi.scm				\
@@ -359,6 +360,7 @@ MODULES =					\
   guix/scripts/import/hexpm.scm			\
   guix/scripts/import/json.scm  		\
   guix/scripts/import/minetest.scm  		\
+  guix/scripts/import/npm-binary.scm		\
   guix/scripts/import/opam.scm			\
   guix/scripts/import/pypi.scm			\
   guix/scripts/import/stackage.scm		\
@@ -557,6 +559,7 @@ SCM_TESTS =					\
   tests/modules.scm				\
   tests/monads.scm				\
   tests/nar.scm				\
+  tests/npm-binary.scm				\
   tests/networking.scm				\
   tests/opam.scm				\
   tests/openpgp.scm				\
diff --git a/doc/guix.texi b/doc/guix.texi
index 69a904473c..566af6e849 100644
--- a/doc/guix.texi
+++ b/doc/guix.texi
@@ -14641,6 +14641,39 @@ Invoking guix import
 in Guix.
 @end table
 
+@item npm-binary
+@cindex npm
+@cindex Node.js
+Import metadata from the @uref{https://registry.npmjs.org, npm
+Registry}, as in this example:
+
+@example
+guix import npm-binary buffer-crc32
+@end example
+
+The npm-binary importer also allows you to specify a version string:
+
+@example
+guix import npm-binary buffer-crc32 1.0.0
+@end example
+
+@quotation Note
+Generated package expressions skip the build step of the
+@code{node-build-system}. As such, generated package expressions often
+refer to transpiled or generated files, instead of being built from
+source.
+@end quotation
+
+Additional options include:
+
+@table @code
+@item --recursive
+@itemx -r
+Traverse the dependency graph of the given upstream package recursively
+and generate package expressions for all those packages that are not yet
+in Guix.
+@end table
+
 @item opam
 @cindex OPAM
 @cindex OCaml
diff --git a/guix/import/npm-binary.scm b/guix/import/npm-binary.scm
new file mode 100644
index 0000000000..57c985baf2
--- /dev/null
+++ b/guix/import/npm-binary.scm
@@ -0,0 +1,277 @@
+;;; GNU Guix --- Functional package management for GNU
+;;; Copyright © 2019, 2020 Timothy Sample <[email-masked]>
+;;; Copyright © 2020, 2023, 2024 Jelle Licht <[email-masked]>
+;;;
+;;; This file is part of GNU Guix.
+;;;
+;;; GNU Guix is free software; you can redistribute it and/or modify it
+;;; under the terms of the GNU General Public License as published by
+;;; the Free Software Foundation; either version 3 of the License, or (at
+;;; your option) any later version.
+;;;
+;;; GNU Guix is distributed in the hope that it will be useful, but
+;;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;;; GNU General Public License for more details.
+;;;
+;;; You should have received a copy of the GNU General Public License
+;;; along with GNU Guix.  If not, see <http://www.gnu.org/licenses/>.
+
+(define-module (guix import npm-binary)
+  #:use-module ((gnu services configuration) #:select (alist?))
+  #:use-module (gcrypt hash)
+  #:use-module (gnu packages)
+  #:use-module (guix base32)
+  #:use-module (guix http-client)
+  #:use-module (guix import json)
+  #:use-module (guix import utils)
+  #:use-module (guix memoization)
+  #:use-module (guix utils)
+  #:use-module (ice-9 match)
+  #:use-module (ice-9 receive)
+  #:use-module (ice-9 regex)
+  #:use-module (json)
+  #:use-module (srfi srfi-1)
+  #:use-module (srfi srfi-26)
+  #:use-module (srfi srfi-41)
+  #:use-module (srfi srfi-9)
+  #:use-module (web client)
+  #:use-module (web response)
+  #:use-module (web uri)
+  #:export (npm-binary-recursive-import
+            npm-binary->guix-package
+            make-versioned-package
+            name+version->symbol))
+
+;; Autoload Guile-Semver so we only have a soft dependency.
+(module-autoload! (current-module)
+		  '(semver)
+                  '(string->semver semver? semver->string semver=? semver>?))
+(module-autoload! (current-module)
+		  '(semver ranges)
+                  '(*semver-range-any* string->semver-range semver-range-contains?))
+
+;; Dist-tags
+(define-json-mapping <dist-tags> make-dist-tags dist-tags?
+  json->dist-tags
+  (latest dist-tags-latest "latest" string->semver))
+
+(define-record-type <versioned-package>
+  (make-versioned-package name version)
+  versioned-package?
+  (name  versioned-package-name)       ;string
+  (version versioned-package-version)) ;string
+
+(define (dependencies->versioned-packages entries)
+  (match entries
+    (((names . versions) ...)
+     (map make-versioned-package names versions))
+    (_ '())))
+
+(define (extract-license license-string)
+  (if (unspecified? license-string)
+      'unspecified!
+      (spdx-string->license license-string)))
+
+(define-json-mapping <dist> make-dist dist?
+  json->dist
+  (tarball dist-tarball))
+
+(define (empty-or-string s)
+  (if (string? s) s ""))
+
+(define-json-mapping <package-revision> make-package-revision package-revision?
+  json->package-revision
+  (name package-revision-name)
+  (version package-revision-version "version"           ;semver
+           string->semver)
+  (home-page package-revision-home-page "homepage")     ;string
+  (dependencies package-revision-dependencies           ;list of versioned-package
+                "dependencies"
+                dependencies->versioned-packages)
+  (dev-dependencies package-revision-dev-dependencies   ;list of versioned-package
+                    "devDependencies" dependencies->versioned-packages)
+  (peer-dependencies package-revision-peer-dependencies ;list of versioned-package
+                    "peerDependencies" dependencies->versioned-packages)
+  (license package-revision-license "license"           ;license | #f
+           (match-lambda
+             ((? unspecified?) #f)
+             ((? string? str) (spdx-string->license str))
+             ((? alist? alist)
+              (match (assoc "type" alist)
+                ((_ . (? string? type))
+                 (spdx-string->license type))
+                (_ #f)))))
+  (description package-revision-description             ;string
+               "description" empty-or-string)
+  (dist package-revision-dist "dist" json->dist))       ;dist
+
+(define (versions->package-revisions versions)
+  (match versions
+    (((version . package-spec) ...)
+     (map json->package-revision package-spec))
+    (_ '())))
+
+(define (versions->package-versions versions)
+  (match versions
+    (((version . package-spec) ...)
+     (map string->semver versions))
+    (_ '())))
+
+(define-json-mapping <meta-package> make-meta-package meta-package?
+  json->meta-package
+  (name meta-package-name)                                       ;string
+  (description meta-package-description)                         ;string
+  (dist-tags meta-package-dist-tags "dist-tags" json->dist-tags) ;dist-tags
+  (revisions meta-package-revisions "versions" versions->package-revisions))
+
+;; TODO: Support other registries
+(define %registry "https://registry.npmjs.org")
+(define %default-page "https://www.npmjs.com/package")
+
+(define (lookup-meta-package name)
+  (let ((json (json-fetch (string-append %registry "/" (uri-encode name)))))
+    (and=> json json->meta-package)))
+
+(define lookup-meta-package* (memoize lookup-meta-package))
+
+(define (meta-package-versions meta)
+  (map package-revision-version
+       (meta-package-revisions meta)))
+
+(define (meta-package-latest meta)
+  (and=> (meta-package-dist-tags meta) dist-tags-latest))
+
+(define* (meta-package-package meta #:optional
+                               (version (meta-package-latest meta)))
+  (match version
+    ((? semver?) (find (lambda (revision)
+                         (semver=? version (package-revision-version revision)))
+                       (meta-package-revisions meta)))
+    ((? string?) (meta-package-package meta (string->semver version)))
+    (_ #f)))
+
+(define* (semver-latest svs #:optional (svr *semver-range-any*))
+  (find (cut semver-range-contains? svr <>)
+        (sort svs semver>?)))
+
+(define* (resolve-package name #:optional (svr *semver-range-any*))
+  (let ((meta (lookup-meta-package* name)))
+    (and meta
+         (let* ((version (semver-latest (or (meta-package-versions meta) '()) svr))
+                (pkg (meta-package-package meta version)))
+           pkg))))
+
+
+;;;
+;;; Converting packages
+;;;
+
+(define (hash-url url)
+  "Downloads the resource at URL and computes the base32 hash for it."
+  (bytevector->nix-base32-string (port-sha256 (http-fetch url))))
+
+(define (npm-name->name npm-name)
+  "Return a Guix package name for the npm package with name NPM-NAME."
+  (define (clean name)
+    (string-map (lambda (chr) (if (char=? chr #\/) #\- chr))
+                (string-filter (negate (cut char=? <> #\@)) name)))
+  (guix-name "node-" (clean npm-name)))
+
+(define (name+version->symbol name version)
+  (string->symbol (string-append name "-" version)))
+
+(define (package-revision->symbol package)
+  (let* ((npm-name (package-revision-name package))
+         (version (semver->string (package-revision-version package)))
+         (name (npm-name->name npm-name)))
+    (name+version->symbol name version)))
+
+(define (npm-package->package-sexp npm-package)
+  "Return the `package' s-expression for an NPM-PACKAGE."
+  (define resolve-spec
+    (match-lambda
+      (($ <versioned-package> name version)
+       (resolve-package name (string->semver-range version)))))
+
+  (if (package-revision? npm-package)
+      (let ((name (package-revision-name npm-package))
+            (version (package-revision-version npm-package))
+            (home-page (package-revision-home-page npm-package))
+            (dependencies (package-revision-dependencies npm-package))
+            (dev-dependencies (package-revision-dev-dependencies npm-package))
+            (peer-dependencies (package-revision-peer-dependencies npm-package))
+            (license (package-revision-license npm-package))
+            (description (package-revision-description npm-package))
+            (dist (package-revision-dist npm-package)))
+        (let* ((name (npm-name->name name))
+               (url (dist-tarball dist))
+               (home-page (if (string? home-page)
+                              home-page
+                              (string-append %default-page "/" (uri-encode name))))
+               (synopsis description)
+               (resolved-deps (map resolve-spec
+                                   (append dependencies peer-dependencies)))
+               (peer-names (map versioned-package-name peer-dependencies))
+               ;; lset-difference for treating peer-dependencies as dependencies,
+               ;; which leads to dependency cycles.  lset-union for treating them as
+               ;; (ignored) dev-dependencies, which leads to broken packages.
+               (dev-names
+                (lset-union string=
+                            (map versioned-package-name dev-dependencies)
+                            peer-names))
+               (extra-phases
+                (match dev-names
+                  (() '())
+                  ((dev-names ...)
+                   `((add-after 'patch-dependencies 'delete-dev-dependencies
+                       (lambda _
+                         (delete-dependencies '(,@(reverse dev-names))))))))))
+          (values
+           `(package
+              (name ,name)
+              (version ,(semver->string (package-revision-version npm-package)))
+              (source (origin
+                        (method url-fetch)
+                        (uri ,url)
+                        (sha256 (base32 ,(hash-url url)))))
+              (build-system node-build-system)
+              (arguments
+               (list
+                #:tests? #f
+                #:phases
+                #~(modify-phases %standard-phases
+                    (delete 'build)
+                    ,@extra-phases)))
+              ,@(match dependencies
+                  (() '())
+                  ((dependencies ...)
+                   `((inputs
+                      (list ,@(map package-revision->symbol resolved-deps))))))
+              (home-page ,home-page)
+              (synopsis ,synopsis)
+              (description ,description)
+              (license ,license))
+           (map (match-lambda (($ <package-revision> name version)
+                               (list name (semver->string version))))
+                resolved-deps))))
+      (values #f '())))
+
+
+;;;
+;;; Interface
+;;;
+
+(define npm-binary->guix-package
+  (lambda* (name #:key (version *semver-range-any*) #:allow-other-keys)
+    (let* ((svr (match version
+                  ((? string?) (string->semver-range version))
+                  (_ version)))
+           (pkg (resolve-package name svr)))
+      (npm-package->package-sexp pkg))))
+
+(define* (npm-binary-recursive-import package-name #:key version)
+  (recursive-import package-name
+                    #:repo->guix-package (memoize npm-binary->guix-package)
+                    #:version version
+                    #:guix-name npm-name->name))
diff --git a/guix/scripts/import.scm b/guix/scripts/import.scm
index 1f34cab088..d724f2bca3 100644
--- a/guix/scripts/import.scm
+++ b/guix/scripts/import.scm
@@ -49,7 +49,7 @@ (define %standard-import-options '())
 
 (define importers '("gnu" "pypi" "cpan" "hackage" "stackage" "egg" "elpa"
                     "gem" "go" "cran" "crate" "texlive" "json" "opam"
-                    "minetest" "elm" "hexpm" "composer"))
+                    "minetest" "elm" "hexpm" "composer" "npm-binary"))
 
 (define (resolve-importer name)
   (let ((module (resolve-interface
diff --git a/guix/scripts/import/npm-binary.scm b/guix/scripts/import/npm-binary.scm
new file mode 100644
index 0000000000..4a4daa7945
--- /dev/null
+++ b/guix/scripts/import/npm-binary.scm
@@ -0,0 +1,114 @@
+;;; GNU Guix --- Functional package management for GNU
+;;; Copyright © 2014 David Thompson <[email-masked]>
+;;; Copyright © 2018 Ricardo Wurmus <[email-masked]>
+;;; Copyright © 2019 Timothy Sample <[email-masked]>
+;;;
+;;; This file is part of GNU Guix.
+;;;
+;;; GNU Guix is free software; you can redistribute it and/or modify it
+;;; under the terms of the GNU General Public License as published by
+;;; the Free Software Foundation; either version 3 of the License, or (at
+;;; your option) any later version.
+;;;
+;;; GNU Guix is distributed in the hope that it will be useful, but
+;;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;;; GNU General Public License for more details.
+;;;
+;;; You should have received a copy of the GNU General Public License
+;;; along with GNU Guix.  If not, see <http://www.gnu.org/licenses/>.
+
+(define-module (guix scripts import npm-binary)
+  #:use-module (guix ui)
+  #:use-module (guix utils)
+  #:use-module (guix scripts)
+  #:use-module (guix import npm-binary)
+  #:use-module (guix scripts import)
+  #:use-module (srfi srfi-1)
+  #:use-module (srfi srfi-11)
+  #:use-module (srfi srfi-37)
+  #:use-module (srfi srfi-41)
+  #:use-module (ice-9 match)
+  #:use-module (ice-9 format)
+  #:export (guix-import-npm-binary))
+
+
+;;;
+;;; Command-line options.
+;;;
+
+(define %default-options
+  '())
+
+(define (show-help)
+  (display (G_ "Usage: guix import npm-binary PACKAGE-NAME [VERSION]
+Import and convert the npm package PACKAGE-NAME using the
+`node-build-system' (but without building the package from source)."))
+  (display (G_ "
+  -h, --help             display this help and exit"))
+  (display (G_ "
+  -r, --recursive        import packages recursively"))
+  (display (G_ "
+  -V, --version          display version information and exit"))
+  (newline)
+  (show-bug-report-information))
+
+(define %options
+  ;; Specification of the command-line options.
+  (cons* (option '(#\h "help") #f #f
+                 (lambda args
+                   (show-help)
+                   (exit 0)))
+         (option '(#\V "version") #f #f
+                 (lambda args
+                   (show-version-and-exit "guix import npm-binary")))
+         (option '(#\r "recursive") #f #f
+                 (lambda (opt name arg result)
+                   (alist-cons 'recursive #t result)))
+         %standard-import-options))
+
+
+;;;
+;;; Entry point.
+;;;
+
+(define (guix-import-npm-binary . args)
+  (define (parse-options)
+    ;; Return the alist of option values.
+    (args-fold* args %options
+                (lambda (opt name arg result)
+                  (leave (G_ "~A: unrecognized option~%") name))
+                (lambda (arg result)
+                  (alist-cons 'argument arg result))
+                %default-options))
+  (let* ((opts (parse-options))
+         (args (filter-map (match-lambda
+                             (('argument . value)
+                              value)
+                             (_ #f))
+                           (reverse opts))))
+    (let loop ((args args))
+      (match args
+        ((package-name version)
+         (match (if (assoc-ref opts 'recursive)
+                    ;; Recursive import
+                    (npm-binary-recursive-import package-name #:version version)
+                    ;; Single import
+                    (npm-binary->guix-package package-name #:version version))
+           ((or #f '())
+            (leave (G_ "failed to download meta-data for package '~a@~a'~%")
+                        package-name version))
+           ((? list? sexps)
+            (map (match-lambda
+                   ((and ('package ('name name) ('version version) . rest) pkg)
+                    `(define-public ,(name+version->symbol name version)
+                       ,pkg))
+                   (_ #f))
+                 sexps))
+           (sexp (list sexp))))
+        ((package-name)
+         (loop (list package-name "*")))
+        (()
+         (leave (G_ "too few arguments~%")))
+        ((many ...)
+         (leave (G_ "too many arguments~%")))))))
diff --git a/tests/npm-binary.scm b/tests/npm-binary.scm
new file mode 100755
index 0000000000..cf85e572b3
--- /dev/null
+++ b/tests/npm-binary.scm
@@ -0,0 +1,146 @@
+;;; GNU Guix --- Functional package management for GNU
+;;; Copyright © 2024 Jelle Licht <[email-masked]>
+;;;
+;;; This file is part of GNU Guix.
+;;;
+;;; GNU Guix is free software; you can redistribute it and/or modify it
+;;; under the terms of the GNU General Public License as published by
+;;; the Free Software Foundation; either version 3 of the License, or (at
+;;; your option) any later version.
+;;;
+;;; GNU Guix is distributed in the hope that it will be useful, but
+;;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;;; GNU General Public License for more details.
+;;;
+;;; You should have received a copy of the GNU General Public License
+;;; along with GNU Guix.  If not, see <http://www.gnu.org/licenses/>.
+(define-module (test-npm-binary)
+  #:use-module ((gcrypt hash)
+                #:select ((sha256 . gcrypt-sha256)))
+  #:use-module (guix import npm-binary)
+  #:use-module (guix base32)
+  #:use-module (guix tests)
+  #:use-module (srfi srfi-64)
+  #:use-module (ice-9 iconv)
+  #:use-module (ice-9 match)
+  #:export (run-test))
+
+(define foo-json
+  "{
+  \"name\": \"foo\",
+  \"dist-tags\": {
+    \"latest\": \"1.2.3\",
+    \"next\": \"2.0.1-beta4\"
+  },
+  \"description\": \"General purpose utilities to foo your bars\",
+  \"homepage\": \"https://github.com/quartz/foo\",
+  \"repository\": \"quartz/foo\",
+  \"versions\": {
+    \"1.2.3\": {
+      \"name\": \"foo\",
+      \"description\": \"General purpose utilities to foo your bars\",
+      \"version\": \"1.2.3\",
+      \"author\": \"Jelle Licht <[email-masked]>\",
+      \"devDependencies\": {
+        \"node-megabuilder\": \"^0.0.2\"
+      },
+      \"dependencies\": {
+        \"bar\": \"^0.1.0\"
+      },
+      \"repository\": {
+        \"url\": \"quartz/foo\"
+      },
+      \"homepage\": \"https://github.com/quartz/foo\",
+      \"license\": \"MIT\",
+      \"dist\": {
+        \"tarball\": \"https://registry.npmjs.org/foo/-/foo-1.2.3.tgz\"
+      }
+    }
+  }
+}")
+
+(define bar-json
+  "{
+  \"name\": \"bar\",
+  \"dist-tags\": {
+    \"latest\": \"0.1.2\"
+  },
+  \"description\": \"Core module in FooBar\",
+  \"homepage\": \"https://github.com/quartz/bar\",
+  \"repository\": \"quartz/bar\",
+  \"versions\": {
+    \"0.1.2\": {
+      \"name\": \"bar\",
+      \"description\": \"Core module in FooBar\",
+      \"version\": \"0.1.2\",
+      \"author\": \"Jelle Licht <[email-masked]>\",
+      \"repository\": {
+        \"url\": \"quartz/bar\"
+      },
+      \"homepage\": \"https://github.com/quartz/bar\",
+      \"license\": \"MIT\",
+      \"dist\": {
+        \"tarball\": \"https://registry.npmjs.org/bar/-/bar-0.1.2.tgz\"
+      }
+    }
+  }
+}")
+
+(define test-source-hash
+  "")
+
+(define test-source
+  "Empty file\n")
+
+(define have-guile-semver?
+  (false-if-exception (resolve-interface '(semver))))
+
+(test-begin "npm")
+
+(unless have-guile-semver? (test-skip 1))
+(test-assert "npm-binary->guix-package"
+  (mock ((guix http-client) http-fetch
+         (lambda* (url #:rest _)
+           (match url
+             ("https://registry.npmjs.org/foo"
+              (values (open-input-string foo-json)
+                      (string-length foo-json)))
+             ("https://registry.npmjs.org/bar"
+              (values (open-input-string bar-json)
+                      (string-length bar-json)))
+             ("https://registry.npmjs.org/foo/-/foo-1.2.3.tgz"
+              (set! test-source-hash
+                    (bytevector->nix-base32-string
+                     (gcrypt-sha256 (string->bytevector test-source "utf-8"))))
+              (values (open-input-string test-source)
+                      (string-length test-source))))))
+        (match (npm-binary->guix-package "foo")
+          (`(package
+              (name "node-foo")
+              (version "1.2.3")
+              (source (origin
+                        (method url-fetch)
+                        (uri "https://registry.npmjs.org/foo/-/foo-1.2.3.tgz")
+                        (sha256
+                         (base32
+                          ,test-source-hash))))
+              (build-system node-build-system)
+              (arguments
+               (list #:tests? #f
+                     #:phases
+                     (gexp (modify-phases %standard-phases
+                             (delete 'build)
+                             (add-after 'patch-dependencies 'delete-dev-dependencies
+                               (lambda _
+                                 (delete-dependencies '("node-megabuilder"))))))))
+              (inputs (list node-bar-0.1.2))
+              (home-page "https://github.com/quartz/foo")
+              (synopsis "General purpose utilities to foo your bars")
+              (description "General purpose utilities to foo your bars")
+              (license license:expat))
+           #t)
+          (x
+           (pk 'fail x #f)))))
+
+(test-end "npm")

base-commit: 4d79a9cd6b5f0d8c5afbab0c6b70ae42740d5470
-- 
2.41.0