diff --git a/invenio_cli/cli/assets.py b/invenio_cli/cli/assets.py index 966cb396..5cc15746 100644 --- a/invenio_cli/cli/assets.py +++ b/invenio_cli/cli/assets.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # Copyright (C) 2020 CERN. -# Copyright (C) 2022 Graz University of Technology. +# Copyright (C) 2022-2025 Graz University of Technology. # # Invenio-Cli is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. @@ -53,6 +53,14 @@ def build(cli_config, no_wipe, production, node_log_file): ) +@assets.command() +@pass_cli_config +def lock(cli_config): + """Create a lockfile for your assets.""" + commands = AssetsCommands(cli_config) + commands.lock() + + @assets.command() @click.argument("path", type=click.Path(exists=True)) @pass_cli_config diff --git a/invenio_cli/commands/assets.py b/invenio_cli/commands/assets.py index d7662949..ded3ff90 100644 --- a/invenio_cli/commands/assets.py +++ b/invenio_cli/commands/assets.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # +# Copyright (C) 2026 California Institute of Technology. # Copyright (C) 2020 CERN. # # Invenio-Cli is free software; you can redistribute it and/or modify it @@ -8,6 +9,7 @@ """Invenio module to ease the creation and management of applications.""" from pathlib import Path +from shutil import copyfile import click @@ -74,6 +76,24 @@ def _build_script(module_pkg): status_code=status_code, ) + def _cache_js_lock_files(self): + """Cache js lock files.""" + instance_path = self.cli_config.get_instance_path() + project_dir = self.cli_config.get_project_dir() + + lock_file = self.cli_config.javascript_package_manager.lock_file_name + target_path = project_dir / lock_file + source_path = instance_path / "assets" / lock_file + if not source_path.is_symlink(): + copyfile(source_path, target_path) + + target_path = project_dir / "package.json" + source_path = instance_path / "assets" / "package.json" + if not source_path.is_symlink(): + copyfile(source_path, target_path) + + return ProcessResponse(output="Cached js lock files.", status_code=0) + def watch_assets(self): """High-level command to watch assets for changes.""" watch_cmd = self.cli_config.python_package_manager.run_command( @@ -137,3 +157,35 @@ def watch_js_module(self, path, link=True): ) return steps + + def lock(self, debug=True, log_file=None): + """Lock assets.""" + py_pkg_man = self.cli_config.python_package_manager + js_pkg_man = self.cli_config.javascript_package_manager + + lock_arg = self.cli_config.javascript_package_manager.lock_dependencies() + + ops = [py_pkg_man.run_command("invenio", "collect", "--verbose")] + ops.append(py_pkg_man.run_command("invenio", "webpack", "clean", "create")) + lock_args = ["invenio", "webpack", "install"] + lock_arg + ops.append(py_pkg_man.run_command(*lock_args)) + ops.append(self._cache_js_lock_files) + messages = { + "build": "Locking assets...", + } + + with env(FLASK_DEBUG="1" if debug else "0"): + for op in ops: + if callable(op): + response = op() + else: + if op[-1] in messages: + click.secho(messages[op[-1]], fg="green") + response = run_interactive( + op, + env={"PIPENV_VERBOSITY": "-1", **js_pkg_man.env_overrides()}, + log_file=log_file, + ) + if response.status_code != 0: + break + return response diff --git a/invenio_cli/commands/local.py b/invenio_cli/commands/local.py index ad6a685d..bf40bed6 100644 --- a/invenio_cli/commands/local.py +++ b/invenio_cli/commands/local.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # +# Copyright (C) 2026 California Institute of Technology. # Copyright (C) 2020 CERN. # Copyright (C) 2022 Graz University of Technology. # @@ -13,6 +14,7 @@ from distutils.dir_util import copy_tree from os import environ from pathlib import Path +from shutil import copyfile from subprocess import Popen as popen import click @@ -79,6 +81,26 @@ def _statics(self): status_code=0, ) + def _copy_js_lock_files(self): + """Copy js lock files.""" + instance_path = self.cli_config.get_instance_path() + project_dir = self.cli_config.get_project_dir() + + lock_file = self.cli_config.javascript_package_manager.lock_file_name + source_path = project_dir / lock_file + target_path = instance_path / "assets" / lock_file + if Path(source_path).exists(): + copyfile(source_path, target_path) + + source_path = project_dir / "package.json" + target_path = instance_path / "assets" / "package.json" + if Path(source_path).exists(): + copyfile(source_path, target_path) + + return ProcessResponse( + output="Copied js lock files to instance.", status_code=0 + ) + def update_statics_and_assets(self, force, debug=False, log_file=None): """High-level command to update less/js/images/... files. @@ -91,11 +113,18 @@ def update_statics_and_assets(self, force, debug=False, log_file=None): if force: ops.append(py_pkg_man.run_command("invenio", "webpack", "clean", "create")) + # We need to copy the js lock files here, since webpack regenerates + # package.json and we want the locked version instead. + ops.append(self._copy_js_lock_files) ops.append(py_pkg_man.run_command("invenio", "webpack", "install")) else: ops.append(py_pkg_man.run_command("invenio", "webpack", "create")) + # We need to copy the js lock files here, since webpack regenerates + # package.json and we want the locked version instead. + ops.append(self._copy_js_lock_files) ops.append(self._statics) ops.append(py_pkg_man.run_command("invenio", "webpack", "build")) + # Keep the same messages for some of the operations for backward compatibility messages = { "build": "Building assets...", diff --git a/invenio_cli/helpers/package_managers.py b/invenio_cli/helpers/package_managers.py index 5bc98fea..dd2501ad 100644 --- a/invenio_cli/helpers/package_managers.py +++ b/invenio_cli/helpers/package_managers.py @@ -192,6 +192,7 @@ class JavascriptPackageManager(ABC): """Interface for creating tool-specific JS package management commands.""" name = None + lock_file_name = None def create_pynpm_package(self, package_json_path: Union[Path, str]) -> NPMPackage: """Create a variant of ``NPMPackage`` with the path to ``package.json``.""" @@ -209,11 +210,16 @@ def package_linking_steps(self) -> List[AssetsFunction]: """Generate steps to link the target package to the project.""" raise NotImplementedError() + def lock_dependencies(self) -> List[str]: + """Update the lock file.""" + raise NotImplementedError() + class NPM(JavascriptPackageManager): """Generate ``npm`` commands for managing JS packages.""" name = "npm" + lock_file_name = "package-lock.json" def create_pynpm_package(self, package_json_path): """Create an ``NPMPackage`` with the path to ``package.json``.""" @@ -223,6 +229,10 @@ def install_local_package(self, path): """Install the local JS package.""" return ["--prefix", str(path)] + def lock_dependencies(self): + """Lock the JS package.""" + return ["--package-lock-only", "--ignore-scripts"] + def env_overrides(self): """Provide environment overrides for building Invenio assets.""" return {"INVENIO_WEBPACKEXT_NPM_PKG_CLS": "pynpm:NPMPackage"} @@ -278,6 +288,7 @@ class PNPM(JavascriptPackageManager): """Generate ``pnpm`` commands for managing JS packages.""" name = "pnpm" + lock_file_name = "pnpm-lock.yaml" def create_pynpm_package(self, package_json_path): """Create a ``PNPMPackage`` with the path to ``package.json``.""" @@ -287,6 +298,10 @@ def install_local_package(self, path): """Install the local JS package.""" return ["-C", str(path)] + def lock_dependencies(self): + """Lock the JS package.""" + return ["--lockfile-only"] + def env_overrides(self): """Provide environment overrides for building Invenio assets.""" return {"INVENIO_WEBPACKEXT_NPM_PKG_CLS": "pynpm:PNPMPackage"}