From d57897e218b97ad4abb68230f1dd02caf718aa6f Mon Sep 17 00:00:00 2001 From: Cyrus Yiu Date: Fri, 31 Jul 2026 20:14:17 -0400 Subject: [PATCH 1/9] Move to resources dir in package --- .../{ => resources}/js_tools/package-lock.json | 0 src/mkcd2app/{ => resources}/js_tools/package.json | 0 .../templates/vite-project/.gitignore | 0 .../templates/vite-project/.prettierignore | 0 .../templates/vite-project/README.md | 0 .../templates/vite-project/eslint.config.js | 0 .../templates/vite-project/index.html | 0 .../templates/vite-project/package-lock.json | 0 .../templates/vite-project/package.json | 4 ++-- .../templates/vite-project/src/App.css | 0 .../templates/vite-project/src/App.tsx | 0 .../vite-project/src/assets/---simulator.html | 0 .../templates/vite-project/src/assets/binary.js | 0 .../templates/vite-project/src/gameConfiguration.ts | 0 .../templates/vite-project/src/global.d.ts | 0 .../templates/vite-project/src/main.tsx | 0 .../templates/vite-project/src/utils/position.ts | 0 .../templates/vite-project/src/utils/toasts.ts | 0 .../templates/vite-project/tsconfig.app.json | 13 ++++++++++--- .../templates/vite-project/tsconfig.json | 0 .../templates/vite-project/tsconfig.node.json | 12 +++++++++--- .../templates/vite-project/vite.config.ts | 0 src/mkcd2app/utils/resources.py | 4 ++-- 23 files changed, 23 insertions(+), 10 deletions(-) rename src/mkcd2app/{ => resources}/js_tools/package-lock.json (100%) rename src/mkcd2app/{ => resources}/js_tools/package.json (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/.gitignore (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/.prettierignore (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/README.md (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/eslint.config.js (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/index.html (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/package-lock.json (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/package.json (93%) rename src/mkcd2app/{ => resources}/templates/vite-project/src/App.css (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/src/App.tsx (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/src/assets/---simulator.html (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/src/assets/binary.js (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/src/gameConfiguration.ts (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/src/global.d.ts (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/src/main.tsx (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/src/utils/position.ts (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/src/utils/toasts.ts (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/tsconfig.app.json (85%) rename src/mkcd2app/{ => resources}/templates/vite-project/tsconfig.json (100%) rename src/mkcd2app/{ => resources}/templates/vite-project/tsconfig.node.json (81%) rename src/mkcd2app/{ => resources}/templates/vite-project/vite.config.ts (100%) diff --git a/src/mkcd2app/js_tools/package-lock.json b/src/mkcd2app/resources/js_tools/package-lock.json similarity index 100% rename from src/mkcd2app/js_tools/package-lock.json rename to src/mkcd2app/resources/js_tools/package-lock.json diff --git a/src/mkcd2app/js_tools/package.json b/src/mkcd2app/resources/js_tools/package.json similarity index 100% rename from src/mkcd2app/js_tools/package.json rename to src/mkcd2app/resources/js_tools/package.json diff --git a/src/mkcd2app/templates/vite-project/.gitignore b/src/mkcd2app/resources/templates/vite-project/.gitignore similarity index 100% rename from src/mkcd2app/templates/vite-project/.gitignore rename to src/mkcd2app/resources/templates/vite-project/.gitignore diff --git a/src/mkcd2app/templates/vite-project/.prettierignore b/src/mkcd2app/resources/templates/vite-project/.prettierignore similarity index 100% rename from src/mkcd2app/templates/vite-project/.prettierignore rename to src/mkcd2app/resources/templates/vite-project/.prettierignore diff --git a/src/mkcd2app/templates/vite-project/README.md b/src/mkcd2app/resources/templates/vite-project/README.md similarity index 100% rename from src/mkcd2app/templates/vite-project/README.md rename to src/mkcd2app/resources/templates/vite-project/README.md diff --git a/src/mkcd2app/templates/vite-project/eslint.config.js b/src/mkcd2app/resources/templates/vite-project/eslint.config.js similarity index 100% rename from src/mkcd2app/templates/vite-project/eslint.config.js rename to src/mkcd2app/resources/templates/vite-project/eslint.config.js diff --git a/src/mkcd2app/templates/vite-project/index.html b/src/mkcd2app/resources/templates/vite-project/index.html similarity index 100% rename from src/mkcd2app/templates/vite-project/index.html rename to src/mkcd2app/resources/templates/vite-project/index.html diff --git a/src/mkcd2app/templates/vite-project/package-lock.json b/src/mkcd2app/resources/templates/vite-project/package-lock.json similarity index 100% rename from src/mkcd2app/templates/vite-project/package-lock.json rename to src/mkcd2app/resources/templates/vite-project/package-lock.json diff --git a/src/mkcd2app/templates/vite-project/package.json b/src/mkcd2app/resources/templates/vite-project/package.json similarity index 93% rename from src/mkcd2app/templates/vite-project/package.json rename to src/mkcd2app/resources/templates/vite-project/package.json index 63636b0..47898f3 100644 --- a/src/mkcd2app/templates/vite-project/package.json +++ b/src/mkcd2app/resources/templates/vite-project/package.json @@ -7,8 +7,8 @@ "dev": "vite", "lint": "eslint ", "writeLint": "eslint --fix ", - "format": "prettier --check .", - "writeFormat": "prettier --write .", + "format": "prettier --check ", + "writeFormat": "prettier --write ", "preview": "vite preview", "build": "tsc -b && vite build", "build:singlefile": "tsc -b && vite build --mode singlefile" diff --git a/src/mkcd2app/templates/vite-project/src/App.css b/src/mkcd2app/resources/templates/vite-project/src/App.css similarity index 100% rename from src/mkcd2app/templates/vite-project/src/App.css rename to src/mkcd2app/resources/templates/vite-project/src/App.css diff --git a/src/mkcd2app/templates/vite-project/src/App.tsx b/src/mkcd2app/resources/templates/vite-project/src/App.tsx similarity index 100% rename from src/mkcd2app/templates/vite-project/src/App.tsx rename to src/mkcd2app/resources/templates/vite-project/src/App.tsx diff --git a/src/mkcd2app/templates/vite-project/src/assets/---simulator.html b/src/mkcd2app/resources/templates/vite-project/src/assets/---simulator.html similarity index 100% rename from src/mkcd2app/templates/vite-project/src/assets/---simulator.html rename to src/mkcd2app/resources/templates/vite-project/src/assets/---simulator.html diff --git a/src/mkcd2app/templates/vite-project/src/assets/binary.js b/src/mkcd2app/resources/templates/vite-project/src/assets/binary.js similarity index 100% rename from src/mkcd2app/templates/vite-project/src/assets/binary.js rename to src/mkcd2app/resources/templates/vite-project/src/assets/binary.js diff --git a/src/mkcd2app/templates/vite-project/src/gameConfiguration.ts b/src/mkcd2app/resources/templates/vite-project/src/gameConfiguration.ts similarity index 100% rename from src/mkcd2app/templates/vite-project/src/gameConfiguration.ts rename to src/mkcd2app/resources/templates/vite-project/src/gameConfiguration.ts diff --git a/src/mkcd2app/templates/vite-project/src/global.d.ts b/src/mkcd2app/resources/templates/vite-project/src/global.d.ts similarity index 100% rename from src/mkcd2app/templates/vite-project/src/global.d.ts rename to src/mkcd2app/resources/templates/vite-project/src/global.d.ts diff --git a/src/mkcd2app/templates/vite-project/src/main.tsx b/src/mkcd2app/resources/templates/vite-project/src/main.tsx similarity index 100% rename from src/mkcd2app/templates/vite-project/src/main.tsx rename to src/mkcd2app/resources/templates/vite-project/src/main.tsx diff --git a/src/mkcd2app/templates/vite-project/src/utils/position.ts b/src/mkcd2app/resources/templates/vite-project/src/utils/position.ts similarity index 100% rename from src/mkcd2app/templates/vite-project/src/utils/position.ts rename to src/mkcd2app/resources/templates/vite-project/src/utils/position.ts diff --git a/src/mkcd2app/templates/vite-project/src/utils/toasts.ts b/src/mkcd2app/resources/templates/vite-project/src/utils/toasts.ts similarity index 100% rename from src/mkcd2app/templates/vite-project/src/utils/toasts.ts rename to src/mkcd2app/resources/templates/vite-project/src/utils/toasts.ts diff --git a/src/mkcd2app/templates/vite-project/tsconfig.app.json b/src/mkcd2app/resources/templates/vite-project/tsconfig.app.json similarity index 85% rename from src/mkcd2app/templates/vite-project/tsconfig.app.json rename to src/mkcd2app/resources/templates/vite-project/tsconfig.app.json index e30ef7d..3abe7e5 100644 --- a/src/mkcd2app/templates/vite-project/tsconfig.app.json +++ b/src/mkcd2app/resources/templates/vite-project/tsconfig.app.json @@ -2,9 +2,14 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "es2023", - "lib": ["ES2023", "DOM"], + "lib": [ + "ES2023", + "DOM" + ], "module": "esnext", - "types": ["vite/client"], + "types": [ + "vite/client" + ], "allowArbitraryExtensions": true, "skipLibCheck": true, /* Bundler mode */ @@ -20,5 +25,7 @@ "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": [ + "src" + ] } diff --git a/src/mkcd2app/templates/vite-project/tsconfig.json b/src/mkcd2app/resources/templates/vite-project/tsconfig.json similarity index 100% rename from src/mkcd2app/templates/vite-project/tsconfig.json rename to src/mkcd2app/resources/templates/vite-project/tsconfig.json diff --git a/src/mkcd2app/templates/vite-project/tsconfig.node.json b/src/mkcd2app/resources/templates/vite-project/tsconfig.node.json similarity index 81% rename from src/mkcd2app/templates/vite-project/tsconfig.node.json rename to src/mkcd2app/resources/templates/vite-project/tsconfig.node.json index 1f8a29b..3bf20da 100644 --- a/src/mkcd2app/templates/vite-project/tsconfig.node.json +++ b/src/mkcd2app/resources/templates/vite-project/tsconfig.node.json @@ -2,8 +2,12 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "target": "es2023", - "lib": ["ES2023"], - "types": ["node"], + "lib": [ + "ES2023" + ], + "types": [ + "node" + ], "skipLibCheck": true, /* Bundler mode */ "module": "nodenext", @@ -17,5 +21,7 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts"] + "include": [ + "vite.config.ts" + ] } diff --git a/src/mkcd2app/templates/vite-project/vite.config.ts b/src/mkcd2app/resources/templates/vite-project/vite.config.ts similarity index 100% rename from src/mkcd2app/templates/vite-project/vite.config.ts rename to src/mkcd2app/resources/templates/vite-project/vite.config.ts diff --git a/src/mkcd2app/utils/resources.py b/src/mkcd2app/utils/resources.py index 60efddb..20150ce 100644 --- a/src/mkcd2app/utils/resources.py +++ b/src/mkcd2app/utils/resources.py @@ -6,13 +6,13 @@ @contextmanager def get_template_path(name: str) -> Iterator[Path]: - ref = files("mkcd2app").joinpath("templates", name) + ref = files("mkcd2app").joinpath("resources", "templates", name) with as_file(ref) as path: yield path @contextmanager def get_js_tools_path() -> Iterator[Path]: - ref = files("mkcd2app").joinpath("js_tools") + ref = files("mkcd2app").joinpath("resources", "js_tools") with as_file(ref) as path: yield path From 16a88444aa980595332232153ad61675ecec256d Mon Sep 17 00:00:00 2001 From: Cyrus Yiu Date: Fri, 31 Jul 2026 20:34:23 -0400 Subject: [PATCH 2/9] Add skeleton for toolchain and target commands --- src/mkcd2app/cli.py | 47 +++++++++++++++++-- src/mkcd2app/main.py | 109 ++++++++++++++++++++++++------------------- 2 files changed, 105 insertions(+), 51 deletions(-) diff --git a/src/mkcd2app/cli.py b/src/mkcd2app/cli.py index 4ea7402..20a9cd0 100644 --- a/src/mkcd2app/cli.py +++ b/src/mkcd2app/cli.py @@ -13,16 +13,17 @@ def generate_and_parse_args() -> Namespace: :return: A `Namespace` object with parsed CLI arguments. """ parser = ArgumentParser( + prog="mkcd2app", description="Convert your MakeCode Arcade games into a " - "standalone offline executable!" + "standalone offline executable!", ) parser.add_argument( "--debug", action="store_true", - help="Enable debug logging. This must go first before the sub command.", + help="Enable debug logging. This must go first before any sub commands.", ) subparsers = parser.add_subparsers(required=True, dest="command") - # build subcommand + parser_build = subparsers.add_parser( "build", help="Build your MakeCode Arcade game." ) @@ -33,6 +34,46 @@ def generate_and_parse_args() -> Namespace: help="Delete the entire build directory before building.", ) + parser_toolchain = subparsers.add_parser( + "toolchain", help="Manage the MakeCode CLI toolchain." + ) + toolchain_subparsers = parser_toolchain.add_subparsers( + required=True, dest="toolchain_command" + ) + toolchain_subparsers.add_parser( + "install", + help="Install the MakeCode CLI toolchain for this specific mkcd2app version.", + ) + toolchain_subparsers.add_parser( + "status", help="Show the installed MakeCode CLI toolchain." + ) + toolchain_subparsers.add_parser( + "uninstall", + help="Uninstall the MakeCode CLI toolchain for this specific mkcd2app version.", + ) + + parser_target = subparsers.add_parser( + "target", help="Manage MakeCode Arcade target versions." + ) + target_subparsers = parser_target.add_subparsers( + required=True, dest="target_command" + ) + parser_target_install = target_subparsers.add_parser( + "install", help="Install a MakeCode Arcade target version." + ) + parser_target_install.add_argument( + "version", type=str, help="Target version to install." + ) + target_subparsers.add_parser( + "list", help="List installed MakeCode Arcade target versions." + ) + parser_target_uninstall = target_subparsers.add_parser( + "uninstall", help="Uninstall a MakeCode Arcade target version." + ) + parser_target_uninstall.add_argument( + "version", type=str, help="Target version to uninstall." + ) + args = parser.parse_args() logger.debug(f"Received arguments: {args}") return args diff --git a/src/mkcd2app/main.py b/src/mkcd2app/main.py index ef7bbd2..7b35267 100644 --- a/src/mkcd2app/main.py +++ b/src/mkcd2app/main.py @@ -1,14 +1,7 @@ import logging -import shutil from pathlib import Path -import redun -import redun.file -from redun import Scheduler - -from mkcd2app.build_project import BuildProjectResult, build_project from mkcd2app.cli import generate_and_parse_args -from mkcd2app.config import load_config_from_yaml from mkcd2app.utils.logger import create_logger, set_all_stdout_logger_levels logger = create_logger(name=__name__, level=logging.INFO) @@ -21,51 +14,71 @@ def main() -> None: set_all_stdout_logger_levels(logging.DEBUG) logger.debug(f"Received arguments: {args}") - if args.command == "build": - logger.debug("Building project") + if args.command == "toolchain": + if args.toolchain_command == "install": + logger.debug("Installing MakeCode CLI toolchain") - config_path = Path(args.config) - logger.debug(f"Loading config from {config_path}") - config_text = config_path.read_text() + elif args.toolchain_command == "status": + logger.debug("Checking MakeCode CLI toolchain status") + + elif args.toolchain_command == "uninstall": + logger.debug("Uninstalling MakeCode CLI toolchain") + elif args.command == "target": + if args.target_command == "install": + installVersion: str = args.version + logger.debug(f"Installing MakeCode CLI target version {installVersion}") - # Parse once only to extract build_dir for the redun DB path. - # The raw YAML text is passed to redun tasks so that argument - # hashing is deterministic (string) rather than pickle-based - # (which is non-deterministic due to pydantic's set fields). - config = load_config_from_yaml(config_text) - build_dir = Path(config.build_dir) + elif args.target_command == "list": + logger.debug("Listing MakeCode CLI target") - if args.clear_cache: - if build_dir.exists(): - logger.warning(f"Clearing build directory {build_dir}") - shutil.rmtree(build_dir) - else: - logger.debug("Build directory does not exist; nothing to clear") + elif args.target_command == "uninstall": + uninstallVersion: str = args.version + logger.debug(f"Uninstalling MakeCode CLI target version {uninstallVersion}") + + elif args.command == "build": + config_path = Path(args.config) + logger.debug(f"Building project with config {config_path}") - build_dir.mkdir(parents=True, exist_ok=True) - db_uri = f"sqlite:///{build_dir.resolve() / '.redun-cache.db'}" - logger.debug(f"redun cache DB: {db_uri}") - # noinspection PyUnresolvedReferences - redun_config = redun.config.Config( - { - "scheduler": {"log_level": "DEBUG"}, - "backend": {"db_uri": db_uri}, - } - ) - scheduler = Scheduler(config=redun_config) - # Load/migrate the backend so the persistent DB is properly set up. - # Without this, providing a custom db_uri skips the automatic - # engine creation and migration that the in-memory default does. - scheduler.load() - results: BuildProjectResult = scheduler.run( - build_project(config_text), - ) - if results.static: - logger.info(f"Static website directory is at {results.static.path}") - if results.static_singlefile: - logger.info( - f"Static single-file HTML is at {results.static_singlefile.path}" - ) + # config_text = config_path.read_text() + # + # # Parse once only to extract build_dir for the redun DB path. + # # The raw YAML text is passed to redun tasks so that argument + # # hashing is deterministic (string) rather than pickle-based + # # (which is non-deterministic due to pydantic's set fields). + # config = load_config_from_yaml(config_text) + # build_dir = Path(config.build_dir) + # + # if args.clear_cache: + # if build_dir.exists(): + # logger.warning(f"Clearing build directory {build_dir}") + # shutil.rmtree(build_dir) + # else: + # logger.debug("Build directory does not exist; nothing to clear") + # + # build_dir.mkdir(parents=True, exist_ok=True) + # db_uri = f"sqlite:///{build_dir.resolve() / '.redun-cache.db'}" + # logger.debug(f"redun cache DB: {db_uri}") + # # noinspection PyUnresolvedReferences + # redun_config = redun.config.Config( + # { + # "scheduler": {"log_level": "DEBUG"}, + # "backend": {"db_uri": db_uri}, + # } + # ) + # scheduler = Scheduler(config=redun_config) + # # Load/migrate the backend so the persistent DB is properly set up. + # # Without this, providing a custom db_uri skips the automatic + # # engine creation and migration that the in-memory default does. + # scheduler.load() + # results: BuildProjectResult = scheduler.run( + # build_project(config_text), + # ) + # if results.static: + # logger.info(f"Static website directory is at {results.static.path}") + # if results.static_singlefile: + # logger.info( + # f"Static single-file HTML is at {results.static_singlefile.path}" + # ) if __name__ == "__main__": From c6f926cbc092a418013993d1fd87456608d9d683 Mon Sep 17 00:00:00 2001 From: Cyrus Yiu Date: Sat, 1 Aug 2026 23:12:04 -0400 Subject: [PATCH 3/9] Toolchain install command --- pyproject.toml | 1 + src/mkcd2app/build_project/inputs/code.py | 2 +- src/mkcd2app/main.py | 7 ++ src/mkcd2app/toolchains/__init__.py | 0 src/mkcd2app/toolchains/install.py | 100 ++++++++++++++++++++++ src/mkcd2app/utils/filesystem.py | 27 ++++++ src/mkcd2app/utils/paths.py | 50 ++++++----- src/mkcd2app/utils/resources.py | 6 +- src/mkcd2app/utils/run_redun_task.py | 27 ++++++ uv.lock | 2 + 10 files changed, 198 insertions(+), 24 deletions(-) create mode 100644 src/mkcd2app/toolchains/__init__.py create mode 100644 src/mkcd2app/toolchains/install.py create mode 100644 src/mkcd2app/utils/filesystem.py create mode 100644 src/mkcd2app/utils/run_redun_task.py diff --git a/pyproject.toml b/pyproject.toml index eea8454..3783e5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "requests", "beautifulsoup4", "pillow", + "platformdirs>=4.11.0", ] [project.scripts] diff --git a/src/mkcd2app/build_project/inputs/code.py b/src/mkcd2app/build_project/inputs/code.py index 364cc82..c94907c 100644 --- a/src/mkcd2app/build_project/inputs/code.py +++ b/src/mkcd2app/build_project/inputs/code.py @@ -17,8 +17,8 @@ ShareLinkCodeSource, UrlAssetSource, ) +from mkcd2app.utils.filesystem import rmtree_robust from mkcd2app.utils.logger import create_logger -from mkcd2app.utils.paths import rmtree_robust from mkcd2app.utils.run import run_cmd logger = create_logger(name=__name__, level=logging.INFO) diff --git a/src/mkcd2app/main.py b/src/mkcd2app/main.py index 7b35267..737d5d0 100644 --- a/src/mkcd2app/main.py +++ b/src/mkcd2app/main.py @@ -2,7 +2,10 @@ from pathlib import Path from mkcd2app.cli import generate_and_parse_args +from mkcd2app.toolchains.install import install_toolchain from mkcd2app.utils.logger import create_logger, set_all_stdout_logger_levels +from mkcd2app.utils.paths import get_redun_db_for_toolchain_path +from mkcd2app.utils.run_redun_task import run_redun_task logger = create_logger(name=__name__, level=logging.INFO) @@ -18,6 +21,10 @@ def main() -> None: if args.toolchain_command == "install": logger.debug("Installing MakeCode CLI toolchain") + run_redun_task( + expr=install_toolchain(), + redun_db_path=get_redun_db_for_toolchain_path(), + ) elif args.toolchain_command == "status": logger.debug("Checking MakeCode CLI toolchain status") diff --git a/src/mkcd2app/toolchains/__init__.py b/src/mkcd2app/toolchains/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mkcd2app/toolchains/install.py b/src/mkcd2app/toolchains/install.py new file mode 100644 index 0000000..a22ed51 --- /dev/null +++ b/src/mkcd2app/toolchains/install.py @@ -0,0 +1,100 @@ +import logging +import shutil +from contextlib import ExitStack +from pathlib import Path +from tempfile import TemporaryDirectory + +from redun import task +from redun.file import ContentDir + +from mkcd2app.utils.logger import create_logger +from mkcd2app.utils.paths import get_js_tools_dir, get_templates_npm_cache_dir +from mkcd2app.utils.resources import get_js_tools_path, get_template_path +from mkcd2app.utils.run import run_cmd + +logger = create_logger(name=__name__, level=logging.INFO) + + +@task(namespace="mkcd2app") +def install_js_tools(js_tools: ContentDir) -> ContentDir: + """ + Installs necessary JS tools for mkcd2app. + + :param js_tools: The ContentDir pointing to the directory containing the + package.json and package-lock.json, which include the tools to install. + :return: A ContentDir pointing to node_modules, to ensure redun sees that this task + has something that depends on its results. + """ + logger.info("Installing JS tools") + + source_path = Path(js_tools.path) + dest_path = get_js_tools_dir() + dest_path.mkdir(parents=True, exist_ok=True) + + logger.debug(f"Copying package files from {source_path} to {dest_path}") + shutil.copy(source_path / "package.json", dest_path / "package.json") + shutil.copy(source_path / "package-lock.json", dest_path / "package-lock.json") + + logger.debug("`npm ci` to download") + run_cmd(["npm", "ci"], cwd=dest_path) + + logger.debug("All JS tools installed") + return ContentDir(str(dest_path / "node_modules")) + + +@task(namespace="mkcd2app") +def warm_npm_cache_for_templates(templates: ContentDir) -> ContentDir: + """ + For every template in the templates directory, copy them to a temporary directory, + run `npm ci --cache CACHE_DIR --prefer-online` where CACHE_DIR + + :param templates: The ContentDir pointing to the directory containing the templates. + :return: A ContentDir pointing to node_modules, to ensure redun sees that this task + has something that depends on its results. + """ + logger.info("Warming npm cache for templates") + + source_path = Path(templates.path) + cache_path = get_templates_npm_cache_dir() + cache_path.mkdir(parents=True, exist_ok=True) + + logger.debug(f"Looking for templates in {source_path}") + + all_templates = list(source_path.iterdir()) + logger.debug(f"Found {len(all_templates)} templates") + + for template in all_templates: + logger.debug(f"Caching packages for template {template.name}") + with TemporaryDirectory() as tmp_dir: + tmp_dir_path = Path(tmp_dir) + logger.debug(f"Copying {template} to {tmp_dir_path}") + shutil.copytree(template, tmp_dir_path, dirs_exist_ok=True) + logger.debug("Caching npm packages") + run_cmd( + ["npm", "ci", "--cache", str(cache_path), "--prefer-online"], + cwd=tmp_dir_path, + ) + + logger.debug(f"npm cache filled at {cache_path}") + return ContentDir(str(cache_path)) + + +@task(namespace="mkcd2app") +def install_toolchain() -> tuple[ContentDir, ContentDir]: + """ + Install the toolchain for this mkcd2app version. + """ + logger.info("Installing toolchain") + + with ExitStack() as stack: + js_tools_path = stack.enter_context(get_js_tools_path()) + templates_path = stack.enter_context(get_template_path()) + + # `npm ci` the necessary tools (`mkc` CLI itself) + js_tools_node_modules = install_js_tools(ContentDir(str(js_tools_path))) + # `npm ci --cache CACHE_DIR --prefer-online` for all templates + templates_npm_cache = warm_npm_cache_for_templates( + ContentDir(str(templates_path)) + ) + + return js_tools_node_modules, templates_npm_cache diff --git a/src/mkcd2app/utils/filesystem.py b/src/mkcd2app/utils/filesystem.py new file mode 100644 index 0000000..f0d6ab8 --- /dev/null +++ b/src/mkcd2app/utils/filesystem.py @@ -0,0 +1,27 @@ +import os +import shutil +import stat +from pathlib import Path +from types import TracebackType +from typing import Any + + +def _remove_readonly( + func: Any, + path: str, + exc_info: tuple[type[BaseException], BaseException, TracebackType], +) -> None: + """shutil.rmtree error handler: clear read-only bit and retry. + + Needed on Windows because git marks files under .git/objects (and + sometimes .git itself) read-only, which makes os.unlink/os.rmdir + raise PermissionError (WinError 5) even though we own the files. + """ + os.chmod(path, stat.S_IWRITE) + func(path) + + +def rmtree_robust(path: Path) -> None: + if not path.exists(): + return + shutil.rmtree(path, onerror=_remove_readonly) diff --git a/src/mkcd2app/utils/paths.py b/src/mkcd2app/utils/paths.py index f0d6ab8..cff83c9 100644 --- a/src/mkcd2app/utils/paths.py +++ b/src/mkcd2app/utils/paths.py @@ -1,27 +1,35 @@ -import os -import shutil -import stat +from importlib.metadata import version from pathlib import Path -from types import TracebackType -from typing import Any +from platformdirs import PlatformDirs -def _remove_readonly( - func: Any, - path: str, - exc_info: tuple[type[BaseException], BaseException, TracebackType], -) -> None: - """shutil.rmtree error handler: clear read-only bit and retry. - Needed on Windows because git marks files under .git/objects (and - sometimes .git itself) read-only, which makes os.unlink/os.rmdir - raise PermissionError (WinError 5) even though we own the files. - """ - os.chmod(path, stat.S_IWRITE) - func(path) +def get_mkcd2app_version() -> str: + return version("mkcd2app") -def rmtree_robust(path: Path) -> None: - if not path.exists(): - return - shutil.rmtree(path, onerror=_remove_readonly) +DIRS = PlatformDirs("mkcd2app", appauthor=False) + + +def get_user_data_dir() -> Path: + return Path(DIRS.user_data_dir) / get_mkcd2app_version() + + +def get_user_state_dir() -> Path: + return Path(DIRS.user_state_dir) / get_mkcd2app_version() + + +def get_toolchain_dir() -> Path: + return get_user_data_dir() / "toolchain" + + +def get_redun_db_for_toolchain_path() -> Path: + return get_user_state_dir() / "redun_db_for_toolchain.sqlite3" + + +def get_js_tools_dir() -> Path: + return get_toolchain_dir() / "js_tools" + + +def get_templates_npm_cache_dir() -> Path: + return get_toolchain_dir() / "templates_npm_cache" diff --git a/src/mkcd2app/utils/resources.py b/src/mkcd2app/utils/resources.py index 20150ce..d931ad0 100644 --- a/src/mkcd2app/utils/resources.py +++ b/src/mkcd2app/utils/resources.py @@ -5,8 +5,10 @@ @contextmanager -def get_template_path(name: str) -> Iterator[Path]: - ref = files("mkcd2app").joinpath("resources", "templates", name) +def get_template_path(name: str | None = None) -> Iterator[Path]: + ref = files("mkcd2app").joinpath("resources", "templates") + if name: + ref.joinpath(name) with as_file(ref) as path: yield path diff --git a/src/mkcd2app/utils/run_redun_task.py b/src/mkcd2app/utils/run_redun_task.py new file mode 100644 index 0000000..af3b0c3 --- /dev/null +++ b/src/mkcd2app/utils/run_redun_task.py @@ -0,0 +1,27 @@ +import logging +from pathlib import Path + +import redun +from redun import Scheduler +from redun.expression import Expression, Result + +from mkcd2app.utils.logger import create_logger + +logger = create_logger(name=__name__, level=logging.INFO) + + +def run_redun_task(expr: Expression[Result] | Result, redun_db_path: Path) -> Result: + redun_db_path.parent.mkdir(parents=True, exist_ok=True) + db_uri = f"sqlite:///{redun_db_path.resolve()}" + logger.debug(f"redun cache DB: {db_uri}") + # noinspection PyUnresolvedReferences + redun_config = redun.config.Config( + { + "scheduler": {"log_level": "DEBUG"}, + "backend": {"db_uri": db_uri}, + } + ) + scheduler = Scheduler(config=redun_config) + scheduler.load() + # TODO: don't forget to remove cache=False after done testing + return scheduler.run(expr, cache=False) diff --git a/uv.lock b/uv.lock index 60dbc3c..3212d6e 100644 --- a/uv.lock +++ b/uv.lock @@ -574,6 +574,7 @@ source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, { name = "pillow" }, + { name = "platformdirs" }, { name = "pydantic" }, { name = "pyyaml" }, { name = "redun" }, @@ -594,6 +595,7 @@ dev = [ requires-dist = [ { name = "beautifulsoup4" }, { name = "pillow" }, + { name = "platformdirs", specifier = ">=4.11.0" }, { name = "pydantic" }, { name = "pyyaml" }, { name = "redun", specifier = "==0.44.1" }, From 00e291ff8c9a6163418c3d2e481b7edf37f3ae1f Mon Sep 17 00:00:00 2001 From: Cyrus Yiu Date: Mon, 3 Aug 2026 18:12:23 -0400 Subject: [PATCH 4/9] Target install command --- src/mkcd2app/build_project/__init__.py | 11 +- src/mkcd2app/build_project/inputs/code.py | 14 +- src/mkcd2app/config/__init__.py | 2 +- src/mkcd2app/main.py | 27 +++- src/mkcd2app/models/__init__.py | 0 .../{config/model.py => models/config.py} | 0 src/mkcd2app/models/metadata.py | 19 +++ .../resources/empty_project/README.md | 1 + .../resources/empty_project/assets.json | 0 src/mkcd2app/resources/empty_project/main.ts | 1 + src/mkcd2app/resources/empty_project/pxt.json | 12 ++ src/mkcd2app/target/__init__.py | 0 src/mkcd2app/target/install.py | 148 ++++++++++++++++++ src/mkcd2app/toolchains/install.py | 12 +- src/mkcd2app/utils/paths.py | 22 +++ src/mkcd2app/utils/resources.py | 11 +- src/mkcd2app/utils/run.py | 9 +- src/mkcd2app/utils/text.py | 39 +++++ 18 files changed, 302 insertions(+), 26 deletions(-) create mode 100644 src/mkcd2app/models/__init__.py rename src/mkcd2app/{config/model.py => models/config.py} (100%) create mode 100644 src/mkcd2app/models/metadata.py create mode 100644 src/mkcd2app/resources/empty_project/README.md create mode 100644 src/mkcd2app/resources/empty_project/assets.json create mode 100644 src/mkcd2app/resources/empty_project/main.ts create mode 100644 src/mkcd2app/resources/empty_project/pxt.json create mode 100644 src/mkcd2app/target/__init__.py create mode 100644 src/mkcd2app/target/install.py create mode 100644 src/mkcd2app/utils/text.py diff --git a/src/mkcd2app/build_project/__init__.py b/src/mkcd2app/build_project/__init__.py index 84c0c52..9db3754 100644 --- a/src/mkcd2app/build_project/__init__.py +++ b/src/mkcd2app/build_project/__init__.py @@ -19,9 +19,12 @@ install_deps_and_build_website_singlefile, ) from mkcd2app.config import load_config_from_yaml -from mkcd2app.config.model import StaticOutput, StaticSinglefileOutput +from mkcd2app.models.config import StaticOutput, StaticSinglefileOutput from mkcd2app.utils.logger import create_logger -from mkcd2app.utils.resources import get_js_tools_path, get_template_path +from mkcd2app.utils.resources import ( + get_resource_js_tools_path, + get_resource_template_path, +) from mkcd2app.utils.run import run_cmd logger = create_logger(name=__name__, level=logging.INFO) @@ -79,10 +82,10 @@ def build_project(config_yaml: str) -> BuildProjectResult: build_dir.mkdir(parents=True, exist_ok=True) with ExitStack() as stack: - js_tools_path = stack.enter_context(get_js_tools_path()) + js_tools_path = stack.enter_context(get_resource_js_tools_path()) js_tools_content = ContentDir(str(js_tools_path)) - template_path = stack.enter_context(get_template_path("vite-project")) + template_path = stack.enter_context(get_resource_template_path("vite-project")) template_content = ContentDir(str(template_path)) # Install `mkc` with `npm ci` in build dir diff --git a/src/mkcd2app/build_project/inputs/code.py b/src/mkcd2app/build_project/inputs/code.py index c94907c..b158001 100644 --- a/src/mkcd2app/build_project/inputs/code.py +++ b/src/mkcd2app/build_project/inputs/code.py @@ -10,7 +10,7 @@ from redun.file import ContentDir, ContentFile from mkcd2app.config import load_config_from_yaml -from mkcd2app.config.model import ( +from mkcd2app.models.config import ( GitHubCodeSource, PathAssetSource, PathCodeSource, @@ -173,15 +173,15 @@ def download_and_mod_supporting_files(config_yaml: str) -> ContentDir: if config.inputs.assets.icon: match config.inputs.assets.icon.root: - case UrlAssetSource(value=url): # type: ignore[misc] - logger.debug(f"Downloading icon from {url}") - res = requests.get(str(url)) + case UrlAssetSource(value=icon_url): + logger.debug(f"Downloading icon from {icon_url}") + res = requests.get(str(icon_url)) res.raise_for_status() buffer = BytesIO(res.content) im = Image.open(buffer) - case PathAssetSource(value=path): # type: ignore[misc] - logger.debug(f"Opening icon from {path}") - im = Image.open(path) + case PathAssetSource(value=icon_path): + logger.debug(f"Opening icon from {icon_path}") + im = Image.open(icon_path) favicon_path = support_path / "favicon.ico" logger.debug(f"Saving favicon to {favicon_path}") im.save(favicon_path) diff --git a/src/mkcd2app/config/__init__.py b/src/mkcd2app/config/__init__.py index 8f1d4c4..d5cbc40 100644 --- a/src/mkcd2app/config/__init__.py +++ b/src/mkcd2app/config/__init__.py @@ -2,7 +2,7 @@ import yaml -from mkcd2app.config.model import BuildConfig +from mkcd2app.models.config import BuildConfig from mkcd2app.utils.logger import create_logger logger = create_logger(name=__name__, level=logging.INFO) diff --git a/src/mkcd2app/main.py b/src/mkcd2app/main.py index 737d5d0..1814a53 100644 --- a/src/mkcd2app/main.py +++ b/src/mkcd2app/main.py @@ -2,10 +2,15 @@ from pathlib import Path from mkcd2app.cli import generate_and_parse_args +from mkcd2app.target.install import install_target from mkcd2app.toolchains.install import install_toolchain from mkcd2app.utils.logger import create_logger, set_all_stdout_logger_levels -from mkcd2app.utils.paths import get_redun_db_for_toolchain_path +from mkcd2app.utils.paths import ( + get_redun_db_for_target_path, + get_redun_db_for_toolchain_path, +) from mkcd2app.utils.run_redun_task import run_redun_task +from mkcd2app.utils.text import raise_for_invalid_strict_semver logger = create_logger(name=__name__, level=logging.INFO) @@ -20,11 +25,11 @@ def main() -> None: if args.command == "toolchain": if args.toolchain_command == "install": logger.debug("Installing MakeCode CLI toolchain") - run_redun_task( expr=install_toolchain(), redun_db_path=get_redun_db_for_toolchain_path(), ) + logger.debug("Toolchain installed") elif args.toolchain_command == "status": logger.debug("Checking MakeCode CLI toolchain status") @@ -32,15 +37,23 @@ def main() -> None: logger.debug("Uninstalling MakeCode CLI toolchain") elif args.command == "target": if args.target_command == "install": - installVersion: str = args.version - logger.debug(f"Installing MakeCode CLI target version {installVersion}") - + install_version: str = args.version + raise_for_invalid_strict_semver(install_version) + logger.debug(f"Installing MakeCode CLI target version {install_version}") + run_redun_task( + expr=install_target(install_version), + redun_db_path=get_redun_db_for_target_path(), + ) + logger.debug(f"Target {install_version} installed") elif args.target_command == "list": logger.debug("Listing MakeCode CLI target") elif args.target_command == "uninstall": - uninstallVersion: str = args.version - logger.debug(f"Uninstalling MakeCode CLI target version {uninstallVersion}") + uninstall_version: str = args.version + raise_for_invalid_strict_semver(uninstall_version) + logger.debug( + f"Uninstalling MakeCode CLI target version {uninstall_version}" + ) elif args.command == "build": config_path = Path(args.config) diff --git a/src/mkcd2app/models/__init__.py b/src/mkcd2app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mkcd2app/config/model.py b/src/mkcd2app/models/config.py similarity index 100% rename from src/mkcd2app/config/model.py rename to src/mkcd2app/models/config.py diff --git a/src/mkcd2app/models/metadata.py b/src/mkcd2app/models/metadata.py new file mode 100644 index 0000000..85c4d97 --- /dev/null +++ b/src/mkcd2app/models/metadata.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, Field, HttpUrl + + +# { +# "simUrl":"https://trg-arcade.userpxt.io/---simulator", +# "cdnUrl":"https://cdn.makecode.com", +# "version":"v0.0.0", +# "target":"arcade", +# "targetVersion":"4.0.14" +# } +class BinaryJSMetadata(BaseModel): + simUrl: HttpUrl = Field(..., description="Simulator URL") + cdnUrl: HttpUrl = Field(..., description="CDN base URL") + version: str = Field(..., description="Package version") + target: str = Field(..., description="Build target name") + targetVersion: str = Field(..., description="Target version string") + + class Config: + extra = "forbid" diff --git a/src/mkcd2app/resources/empty_project/README.md b/src/mkcd2app/resources/empty_project/README.md new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/src/mkcd2app/resources/empty_project/README.md @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/mkcd2app/resources/empty_project/assets.json b/src/mkcd2app/resources/empty_project/assets.json new file mode 100644 index 0000000..e69de29 diff --git a/src/mkcd2app/resources/empty_project/main.ts b/src/mkcd2app/resources/empty_project/main.ts new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/mkcd2app/resources/empty_project/main.ts @@ -0,0 +1 @@ + diff --git a/src/mkcd2app/resources/empty_project/pxt.json b/src/mkcd2app/resources/empty_project/pxt.json new file mode 100644 index 0000000..782e85f --- /dev/null +++ b/src/mkcd2app/resources/empty_project/pxt.json @@ -0,0 +1,12 @@ +{ + "name": "empty_project", + "description": "", + "dependencies": { + "device": "*" + }, + "files": [ + "main.ts", + "README.md", + "assets.json" + ] +} diff --git a/src/mkcd2app/target/__init__.py b/src/mkcd2app/target/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mkcd2app/target/install.py b/src/mkcd2app/target/install.py new file mode 100644 index 0000000..df40b40 --- /dev/null +++ b/src/mkcd2app/target/install.py @@ -0,0 +1,148 @@ +import json +import logging +import shutil +from contextlib import ExitStack +from pathlib import Path +from tempfile import TemporaryDirectory + +import requests +from bs4 import BeautifulSoup +from redun import task +from redun.file import ContentDir, ContentFile + +from mkcd2app.models.metadata import BinaryJSMetadata +from mkcd2app.utils.logger import create_logger +from mkcd2app.utils.paths import ( + get_js_tools_bin_dir, + get_sim_html_path, +) +from mkcd2app.utils.resources import get_resource_empty_project_path +from mkcd2app.utils.run import run_cmd +from mkcd2app.utils.text import extract_meta_comment + +logger = create_logger(name=__name__, level=logging.INFO) + + +@task(namespace="mkcd2app") +def warm_mkc_cache_for_version_and_get_binary_js_metadata( + empty_prj: ContentDir, version: str +) -> str: + """ + Warm the mkc compiler cache by building an empty project for the target MakeCode + Arcade version in a temporary directory. + + :param empty_prj: The ContentDir pointing to the directory containing the empty + MakeCode Arcade project. + :param version: The MakeCode Arcade version to target. E.g., "4.0.14". Must be + explicit 3 num sem ver, not just like "4.0" or "4", and do not include a "v". + :return: A BinaryJSMetadata model dumped to a JSON string. + """ + logger.info(f"Warming mkc compiler cache for MakeCode Arcade version {version}") + + empty_prj_path = Path(empty_prj.path) + + with TemporaryDirectory() as tmp_dir: + tmp_dir_path = Path(tmp_dir) + + logger.debug(f"Copying {empty_prj_path} to {tmp_dir_path}") + shutil.copytree(empty_prj_path, tmp_dir_path, dirs_exist_ok=True) + + mkc_json_path = tmp_dir_path / "mkc.json" + logger.debug("Writing mkc.json") + mkc_json = {"targetWebsite": f"https://arcade.makecode.com/v{version}"} + mkc_json_path.write_text(json.dumps(mkc_json)) + + logger.debug("Running build to warm cache") + run_cmd( + ["mkc", "build", "-j"], cwd=tmp_dir_path, which_path=get_js_tools_bin_dir() + ) + + binary_js_path = tmp_dir_path / "built" / "binary.js" + logger.debug(f"binary.js available at {binary_js_path}") + + logger.debug("Finished mkc cache warming, extracting binary.js metadata") + metadata = extract_meta_comment(binary_js_path) + logger.debug(f"{metadata=}") + return metadata.model_dump_json() + + +@task(namespace="mkcd2app") +def download_sim(bin_js_metadata: str, version: str) -> ContentFile: + """ + Download the simulator HTML and the supporting files needed to run binary.js for the + website. + + :param bin_js_metadata: The binary JS metadata JSON as a string. + :param version: The MakeCode Arcade version to target. E.g., "4.0.14". Must be + explicit 3 num sem ver, not just like "4.0" or "4", and do not include a "v". + :return: A ContentFile that points to the ---simulator.html. + """ + metadata = BinaryJSMetadata.model_validate_json(bin_js_metadata) + logger.info( + f"Downloading simulator for MakeCode Arcade version {metadata.targetVersion}" + ) + + logger.debug(f"Downloading main simulator file from {metadata.simUrl}") + res = requests.get(str(metadata.simUrl)) + res.raise_for_status() + sim_html = res.text + + logger.debug( + f"Analyzing sim HTML ({len(sim_html)} chars) for required CSS and JS files" + ) + soup = BeautifulSoup(sim_html, features="html.parser") + css_links = soup.find_all("link", rel="stylesheet") + js_scripts = soup.find_all("script") + logger.debug(f"Found {len(css_links)} CSS links and {len(js_scripts)} JS scripts") + for css in css_links: + url = css.get("href") + if url: + logger.debug(f"Downloading CSS file {url}") + res = requests.get(str(url)) + res.raise_for_status() + style_tag = soup.new_tag("style") + style_tag.string = res.text + css.replace_with(style_tag) + logger.debug(f"Inlined CSS from {url}") + for js in js_scripts: + url = js.get("src") + if url: + logger.debug(f"Downloading JS file {url}") + res = requests.get(str(url)) + res.raise_for_status() + js.string = res.text + del js["src"] + logger.debug(f"Inlined JS from {url}") + new_sim_html = soup.prettify(formatter="html5") + + path = get_sim_html_path(version) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(new_sim_html) + + logger.debug(f"Wrote simulator HTML to {path}") + + return ContentFile(str(path)) + + +@task(namespace="mkcd2app") +def install_target(version: str) -> ContentFile: + """ + Install a MakeCode Arcade target version for this mkcd2app version. + + :param version: The MakeCode Arcade version to target. E.g., "4.0.14". Must be + explicit 3 num sem ver, not just like "4.0" or "4", and do not include a "v". + :return: A ContentFile that points to the ---simulator.html for the installed version. + """ + logger.info(f"Installing MakeCode Arcade target version {version}") + + with ExitStack() as stack: + empty_prj_path = stack.enter_context(get_resource_empty_project_path()) + + # `mkc build -j` an empty project with the correct version + metadata = warm_mkc_cache_for_version_and_get_binary_js_metadata( + ContentDir(str(empty_prj_path)), version + ) + # Download ---simulator.html and supporting files, mod into single HTML file + sim_html = download_sim(metadata, version) + + return sim_html diff --git a/src/mkcd2app/toolchains/install.py b/src/mkcd2app/toolchains/install.py index a22ed51..2454c29 100644 --- a/src/mkcd2app/toolchains/install.py +++ b/src/mkcd2app/toolchains/install.py @@ -9,7 +9,10 @@ from mkcd2app.utils.logger import create_logger from mkcd2app.utils.paths import get_js_tools_dir, get_templates_npm_cache_dir -from mkcd2app.utils.resources import get_js_tools_path, get_template_path +from mkcd2app.utils.resources import ( + get_resource_js_tools_path, + get_resource_template_path, +) from mkcd2app.utils.run import run_cmd logger = create_logger(name=__name__, level=logging.INFO) @@ -69,6 +72,7 @@ def warm_npm_cache_for_templates(templates: ContentDir) -> ContentDir: tmp_dir_path = Path(tmp_dir) logger.debug(f"Copying {template} to {tmp_dir_path}") shutil.copytree(template, tmp_dir_path, dirs_exist_ok=True) + logger.debug("Caching npm packages") run_cmd( ["npm", "ci", "--cache", str(cache_path), "--prefer-online"], @@ -83,12 +87,14 @@ def warm_npm_cache_for_templates(templates: ContentDir) -> ContentDir: def install_toolchain() -> tuple[ContentDir, ContentDir]: """ Install the toolchain for this mkcd2app version. + :return: A tuple of two ContentDirs, where the first one is the node_modules folder + for the JS tools, and the other one is the cache directory for the templates. """ logger.info("Installing toolchain") with ExitStack() as stack: - js_tools_path = stack.enter_context(get_js_tools_path()) - templates_path = stack.enter_context(get_template_path()) + js_tools_path = stack.enter_context(get_resource_js_tools_path()) + templates_path = stack.enter_context(get_resource_template_path()) # `npm ci` the necessary tools (`mkc` CLI itself) js_tools_node_modules = install_js_tools(ContentDir(str(js_tools_path))) diff --git a/src/mkcd2app/utils/paths.py b/src/mkcd2app/utils/paths.py index cff83c9..6a86e41 100644 --- a/src/mkcd2app/utils/paths.py +++ b/src/mkcd2app/utils/paths.py @@ -19,6 +19,9 @@ def get_user_state_dir() -> Path: return Path(DIRS.user_state_dir) / get_mkcd2app_version() +# Toolchain stuff + + def get_toolchain_dir() -> Path: return get_user_data_dir() / "toolchain" @@ -31,5 +34,24 @@ def get_js_tools_dir() -> Path: return get_toolchain_dir() / "js_tools" +def get_js_tools_bin_dir() -> Path: + return get_js_tools_dir() / "node_modules" / ".bin" + + def get_templates_npm_cache_dir() -> Path: return get_toolchain_dir() / "templates_npm_cache" + + +# Target stuff + + +def get_target_dir() -> Path: + return get_user_data_dir() / "target" + + +def get_redun_db_for_target_path() -> Path: + return get_user_state_dir() / "redun_db_for_target.sqlite3" + + +def get_sim_html_path(v: str) -> Path: + return get_target_dir() / v / "---simulator.html" diff --git a/src/mkcd2app/utils/resources.py b/src/mkcd2app/utils/resources.py index d931ad0..aff6db0 100644 --- a/src/mkcd2app/utils/resources.py +++ b/src/mkcd2app/utils/resources.py @@ -5,7 +5,7 @@ @contextmanager -def get_template_path(name: str | None = None) -> Iterator[Path]: +def get_resource_template_path(name: str | None = None) -> Iterator[Path]: ref = files("mkcd2app").joinpath("resources", "templates") if name: ref.joinpath(name) @@ -14,7 +14,14 @@ def get_template_path(name: str | None = None) -> Iterator[Path]: @contextmanager -def get_js_tools_path() -> Iterator[Path]: +def get_resource_js_tools_path() -> Iterator[Path]: ref = files("mkcd2app").joinpath("resources", "js_tools") with as_file(ref) as path: yield path + + +@contextmanager +def get_resource_empty_project_path() -> Iterator[Path]: + ref = files("mkcd2app").joinpath("resources", "empty_project") + with as_file(ref) as path: + yield path diff --git a/src/mkcd2app/utils/run.py b/src/mkcd2app/utils/run.py index 08721cb..5bd4b5c 100644 --- a/src/mkcd2app/utils/run.py +++ b/src/mkcd2app/utils/run.py @@ -22,7 +22,11 @@ def __init__( super().__init__(f"{command} failed ({return_code}) in {cwd}") -def run_cmd(command: list[str], cwd: Path | str) -> str: +def run_cmd( + command: list[str], + cwd: Path | str, + which_path: str | os.PathLike[str] | None = None, +) -> str: """ Runs a command (as a list, no shell) and captures its output. @@ -32,11 +36,12 @@ def run_cmd(command: list[str], cwd: Path | str) -> str: :param command: The command as a list of arguments, e.g. ``["npm", "ci"]``. :param cwd: The working directory to execute the command in. + :param which_path: The path to pass to shutil.which, to search for the binary. :return: The stdout. :raises BuildError: If the command fails. :raises FileNotFoundError: If the executable cannot be found on PATH. """ - resolved = shutil.which(command[0]) + resolved = shutil.which(command[0], path=which_path) if resolved is None: raise FileNotFoundError( f"Executable '{command[0]}' not found on PATH. " diff --git a/src/mkcd2app/utils/text.py b/src/mkcd2app/utils/text.py new file mode 100644 index 0000000..48f1a77 --- /dev/null +++ b/src/mkcd2app/utils/text.py @@ -0,0 +1,39 @@ +import re +from pathlib import Path + +from mkcd2app.models.metadata import BinaryJSMetadata + + +def extract_meta_comment(file_path: Path) -> BinaryJSMetadata: + """ + Extracts the // meta={} data from the binary.js file. + + :param file_path: Path to the binary.js + :return: The metadata (a BinaryJSMetadata) + :raises ValueError: If the meta comment is not found in file. + """ + with file_path.open("rt") as f: + for line in f: + line = line.strip() + if line.startswith("// meta="): + # Extract everything after '// meta=' + json_str = line.split("// meta=", 1)[1] + return BinaryJSMetadata.model_validate_json(json_str) + + raise ValueError("Meta comment not found in file.") + + +# Thanks Gemini +STRICT_NUMERIC_SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") + + +def is_strict_semver(version: str) -> bool: + """Validates string using official SemVer 2.0.0 regex pattern.""" + return bool(STRICT_NUMERIC_SEMVER.match(version)) + + +def raise_for_invalid_strict_semver(version: str) -> None: + if not is_strict_semver(version): + raise ValueError( + f"Version '{version}' is not a valid strict numeric semver (e.g., 1.2.3)." + ) From 3d03118e6ec060a52536130a234e8756ccf833d2 Mon Sep 17 00:00:00 2001 From: Cyrus Yiu Date: Mon, 3 Aug 2026 20:10:50 -0400 Subject: [PATCH 5/9] Fix build command to use toolchain/targets --- examples/Racers offline.yaml | 58 +++++++++++++++++++ examples/Racers.yaml | 7 +++ src/mkcd2app/build_project/__init__.py | 25 +++----- src/mkcd2app/build_project/inputs/code.py | 66 +++++++--------------- src/mkcd2app/main.py | 69 ++++++++++------------- src/mkcd2app/models/config.py | 1 + 6 files changed, 123 insertions(+), 103 deletions(-) create mode 100644 examples/Racers offline.yaml diff --git a/examples/Racers offline.yaml b/examples/Racers offline.yaml new file mode 100644 index 0000000..e5de0f2 --- /dev/null +++ b/examples/Racers offline.yaml @@ -0,0 +1,58 @@ +# Example configuration file for MakeCode Arcade to App to compile to static +# HTML, CSS, and JS files + +# Config version (single number, incremented if breaking change to schema is +# made) +version: 1 + +project: + name: Racers + path_friendly_name: racers + description: "Enjoy the high-speed thrills of car racing in MakeCode Arcade! + For the MakeCode Arcade Mini Game Jam #3." + author: Cyrus Yiu + version: 1.3.2 + # This is what the window title will be + # This will also be used for the executable file name for Electron outputs + # You can use {NAME} or {VERSION} or {AUTHOR} to substitute the correct + # values + title: "{NAME} v{VERSION}" + +inputs: + # Can be share_link, github, or path + code: + type: path + value: "E:/Racers" + + assets: + # Icon is optional but highly recommended, it will be used as the favicon + # and app icon + icon: + type: path + value: "E:/Racers/Racers icon.png" + +# Path where the build process will take place +build_dir: "./examples/Racers-offline-build" + +# The MakeCode Arcade version to target +# Must be explicit 3 number sem ver, do not add a "v" at the beginning +# To find it, go to https://arcade.makecode.com/ and click on the settings +# cogwheel button, click About..., and see "arcade version: x.y.z" +# As of the time of writing this, the latest version is 4.0.14 +target: 2.0.48 + +# You can have multiple outputs +# Currently available options are static, static-singlefile, electron, and +# tauri +outputs: + - type: static + - type: static-singlefile +# - type: electron +# window: +# width: 640 +# height: 480 +# - type: tauri +# identifier: com.unsignedarduino.racers +# window: +# width: 640 +# height: 480 diff --git a/examples/Racers.yaml b/examples/Racers.yaml index 841ee2d..210e28f 100644 --- a/examples/Racers.yaml +++ b/examples/Racers.yaml @@ -48,6 +48,13 @@ inputs: # Path where the build process will take place build_dir: "./examples/Racers-build" +# The MakeCode Arcade version to target +# Must be explicit 3 number sem ver, do not add a "v" at the beginning +# To find it, go to https://arcade.makecode.com/ and click on the settings +# cogwheel button, click About..., and see "arcade version: x.y.z" +# As of the time of writing this, the latest version is 4.0.14 +target: 4.0.14 + # You can have multiple outputs # Currently available options are static, static-singlefile, electron, and # tauri diff --git a/src/mkcd2app/build_project/__init__.py b/src/mkcd2app/build_project/__init__.py index 9db3754..ed5b9c9 100644 --- a/src/mkcd2app/build_project/__init__.py +++ b/src/mkcd2app/build_project/__init__.py @@ -9,7 +9,7 @@ from mkcd2app.build_project.inputs.code import ( build_binary_js, - download_and_mod_supporting_files, + copy_support_files, fetch_code, ) from mkcd2app.build_project.website import ( @@ -21,10 +21,7 @@ from mkcd2app.config import load_config_from_yaml from mkcd2app.models.config import StaticOutput, StaticSinglefileOutput from mkcd2app.utils.logger import create_logger -from mkcd2app.utils.resources import ( - get_resource_js_tools_path, - get_resource_template_path, -) +from mkcd2app.utils.resources import get_resource_template_path from mkcd2app.utils.run import run_cmd logger = create_logger(name=__name__, level=logging.INFO) @@ -82,22 +79,15 @@ def build_project(config_yaml: str) -> BuildProjectResult: build_dir.mkdir(parents=True, exist_ok=True) with ExitStack() as stack: - js_tools_path = stack.enter_context(get_resource_js_tools_path()) - js_tools_content = ContentDir(str(js_tools_path)) - template_path = stack.enter_context(get_resource_template_path("vite-project")) - template_content = ContentDir(str(template_path)) + template_content = ContentDir(str(template_path / "vite-project")) - # Install `mkc` with `npm ci` in build dir - node_modules_for_mkc = install_mkcd_build_tools(config_yaml, js_tools_content) # Fetch game source code with `mkc`, `git`, or copy from disk - code_path = fetch_code(config_yaml, node_modules_for_mkc) + code_path = fetch_code(config_yaml) # Build binary.js with `mkc` bin_js_path = build_binary_js(config_yaml, code_path) - # Download supporting files to run binary.js, including ---simulator.html and all - # it's references, and get favicon.ico if present - support_path = download_and_mod_supporting_files(config_yaml) - + # Copy ---simulator.html from target dir and get favicon.ico if present + support_path = copy_support_files(config_yaml) # Copy website template (clean copy with template files only) website_path = copy_website_template(config_yaml, template_content) # Copy + fill (separate dir so stages don't mutate each other's @@ -107,7 +97,8 @@ def build_project(config_yaml: str) -> BuildProjectResult: ) results = BuildProjectResult() - + # Build the website outputs + # Electron and Tauri outputs depend on static_singlefile so redun figures it out logger.debug(f"{config.outputs=}") for output in config.outputs: match output.root: diff --git a/src/mkcd2app/build_project/inputs/code.py b/src/mkcd2app/build_project/inputs/code.py index b158001..9759623 100644 --- a/src/mkcd2app/build_project/inputs/code.py +++ b/src/mkcd2app/build_project/inputs/code.py @@ -4,7 +4,6 @@ from pathlib import Path import requests -from bs4 import BeautifulSoup from PIL import Image from redun import task from redun.file import ContentDir, ContentFile @@ -19,13 +18,14 @@ ) from mkcd2app.utils.filesystem import rmtree_robust from mkcd2app.utils.logger import create_logger +from mkcd2app.utils.paths import get_js_tools_bin_dir, get_sim_html_path from mkcd2app.utils.run import run_cmd logger = create_logger(name=__name__, level=logging.INFO) @task(namespace="mkcd2app") -def fetch_code(config_yaml: str, node_modules_for_mkc: ContentDir) -> ContentDir: +def fetch_code(config_yaml: str) -> ContentDir: """ Download/clone/copy the source code to the build directory. @@ -33,8 +33,6 @@ def fetch_code(config_yaml: str, node_modules_for_mkc: ContentDir) -> ContentDir Example output: ./racers-source :param config_yaml: The raw YAML text of the config file. - :param node_modules_for_mkc: redun.ContentDir that points to the node_modules - directory, this ensures that this task depends on `mkc` being installed. :return: A redun.ContentDir that points to the source code. """ config = load_config_from_yaml(config_yaml) @@ -48,10 +46,13 @@ def fetch_code(config_yaml: str, node_modules_for_mkc: ContentDir) -> ContentDir match config.inputs.code.root: case ShareLinkCodeSource(value=url): - logger.debug(f"Downloading source code from {url}") + logger.debug(f"Downloading source code from {url} with `mkc` CLI") code_path.mkdir(parents=True) - logger.debug(f"Using `mkc` from {node_modules_for_mkc}") - run_cmd(["npx", "mkc", "download", str(url)], cwd=code_path) + run_cmd( + ["mkc", "download", str(url)], + cwd=code_path, + which_path=get_js_tools_bin_dir(), + ) case GitHubCodeSource(value=url, checkout=checkout_target): logger.debug(f"Cloning source code from {url}@{checkout_target}") abs_code_path = code_path.resolve() @@ -94,7 +95,11 @@ def build_binary_js(config_yaml: str, code_path: ContentDir) -> ContentFile: cwd = Path(code_path.path) logger.debug(f"Building in cwd {cwd}") - run_cmd(["npx", "mkc", "build", "-j"], cwd=cwd) + run_cmd( + ["mkc", "build", "-j"], + cwd=cwd, + which_path=get_js_tools_bin_dir(), + ) bin_js_path = cwd / "built" / "binary.js" logger.debug(f"binary.js available at {bin_js_path}") @@ -112,7 +117,7 @@ def build_binary_js(config_yaml: str, code_path: ContentDir) -> ContentFile: @task(namespace="mkcd2app") -def download_and_mod_supporting_files(config_yaml: str) -> ContentDir: +def copy_support_files(config_yaml: str) -> ContentDir: """ Download and modify all supporting files needed to run binary.js for the website @@ -128,48 +133,17 @@ def download_and_mod_supporting_files(config_yaml: str) -> ContentDir: Path(config.build_dir) / f"{config.project.path_friendly_name}-binary-js-support" ) - logger.info(f"Downloading supporting files to {support_path}") + target = config.target + logger.info( + f"Copying ---simulator.html for MakeCode Arcade {target} to {support_path}" + ) # Clean previous output, not wasteful because redun handles caching if support_path.exists(): shutil.rmtree(support_path) support_path.mkdir(parents=True) - - logger.debug("Downloading main simulator file") - res = requests.get("https://trg-arcade.userpxt.io/---simulator") - res.raise_for_status() - sim_html = res.text - - logger.debug( - f"Analyzing sim HTML ({len(sim_html)} chars) for required CSS and JS files" - ) - soup = BeautifulSoup(sim_html, features="html.parser") - css_links = soup.find_all("link", rel="stylesheet") - js_scripts = soup.find_all("script") - logger.debug(f"Found {len(css_links)} CSS links and {len(js_scripts)} JS scripts") - for css in css_links: - url = css.get("href") - if url: - logger.debug(f"Downloading CSS file {url}") - res = requests.get(str(url)) - res.raise_for_status() - style_tag = soup.new_tag("style") - style_tag.string = res.text - css.replace_with(style_tag) - logger.debug(f"Inlined CSS from {url}") - for js in js_scripts: - url = js.get("src") - if url: - logger.debug(f"Downloading JS file {url}") - res = requests.get(str(url)) - res.raise_for_status() - js.string = res.text - del js["src"] - logger.debug(f"Inlined JS from {url}") - new_sim_html = soup.prettify(formatter="html5") - path = support_path / "---simulator.html" - path.write_text(new_sim_html) - logger.debug(f"Wrote modified simulator HTML to {path}") + # Only one file to copy + shutil.copy(get_sim_html_path(target), support_path) if config.inputs.assets.icon: match config.inputs.assets.icon.root: diff --git a/src/mkcd2app/main.py b/src/mkcd2app/main.py index 1814a53..b9ec9d8 100644 --- a/src/mkcd2app/main.py +++ b/src/mkcd2app/main.py @@ -1,7 +1,10 @@ import logging +import shutil from pathlib import Path +from mkcd2app.build_project import BuildProjectResult, build_project from mkcd2app.cli import generate_and_parse_args +from mkcd2app.config import load_config_from_yaml from mkcd2app.target.install import install_target from mkcd2app.toolchains.install import install_toolchain from mkcd2app.utils.logger import create_logger, set_all_stdout_logger_levels @@ -59,46 +62,32 @@ def main() -> None: config_path = Path(args.config) logger.debug(f"Building project with config {config_path}") - # config_text = config_path.read_text() - # - # # Parse once only to extract build_dir for the redun DB path. - # # The raw YAML text is passed to redun tasks so that argument - # # hashing is deterministic (string) rather than pickle-based - # # (which is non-deterministic due to pydantic's set fields). - # config = load_config_from_yaml(config_text) - # build_dir = Path(config.build_dir) - # - # if args.clear_cache: - # if build_dir.exists(): - # logger.warning(f"Clearing build directory {build_dir}") - # shutil.rmtree(build_dir) - # else: - # logger.debug("Build directory does not exist; nothing to clear") - # - # build_dir.mkdir(parents=True, exist_ok=True) - # db_uri = f"sqlite:///{build_dir.resolve() / '.redun-cache.db'}" - # logger.debug(f"redun cache DB: {db_uri}") - # # noinspection PyUnresolvedReferences - # redun_config = redun.config.Config( - # { - # "scheduler": {"log_level": "DEBUG"}, - # "backend": {"db_uri": db_uri}, - # } - # ) - # scheduler = Scheduler(config=redun_config) - # # Load/migrate the backend so the persistent DB is properly set up. - # # Without this, providing a custom db_uri skips the automatic - # # engine creation and migration that the in-memory default does. - # scheduler.load() - # results: BuildProjectResult = scheduler.run( - # build_project(config_text), - # ) - # if results.static: - # logger.info(f"Static website directory is at {results.static.path}") - # if results.static_singlefile: - # logger.info( - # f"Static single-file HTML is at {results.static_singlefile.path}" - # ) + config_text = config_path.read_text() + + # Parse once only to extract build_dir for the redun DB path. + # The raw YAML text is passed to redun tasks so that argument + # hashing is deterministic (string) rather than pickle-based + # (which is non-deterministic due to pydantic's set fields). + config = load_config_from_yaml(config_text) + build_dir = Path(config.build_dir) + + if args.clear_cache: + if build_dir.exists(): + logger.warning(f"Clearing build directory {build_dir}") + shutil.rmtree(build_dir) + else: + logger.debug("Build directory does not exist; nothing to clear") + + results: BuildProjectResult = run_redun_task( + build_project(config_text), build_dir.resolve() / ".redun-cache.db" + ) + + if results.static: + logger.info(f"Static website directory is at {results.static.path}") + if results.static_singlefile: + logger.info( + f"Static single-file HTML is at {results.static_singlefile.path}" + ) if __name__ == "__main__": diff --git a/src/mkcd2app/models/config.py b/src/mkcd2app/models/config.py index 6e13453..d08a245 100644 --- a/src/mkcd2app/models/config.py +++ b/src/mkcd2app/models/config.py @@ -109,6 +109,7 @@ class BuildConfig(BaseModel): project: Project inputs: Inputs build_dir: str = Field(..., alias="build_dir") + target: str = Field(..., alias="target") outputs: list[OutputOption] class Config: From 22dee24a93d94a65802b12b24efe05eaa8cce9b1 Mon Sep 17 00:00:00 2001 From: Cyrus Yiu Date: Mon, 3 Aug 2026 20:36:39 -0400 Subject: [PATCH 6/9] Toolchain uninstall command --- src/mkcd2app/main.py | 7 +++++++ src/mkcd2app/toolchains/install.py | 5 ++++- src/mkcd2app/toolchains/uninstall.py | 23 +++++++++++++++++++++++ src/mkcd2app/utils/filesystem.py | 2 -- 4 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 src/mkcd2app/toolchains/uninstall.py diff --git a/src/mkcd2app/main.py b/src/mkcd2app/main.py index b9ec9d8..7742406 100644 --- a/src/mkcd2app/main.py +++ b/src/mkcd2app/main.py @@ -7,6 +7,7 @@ from mkcd2app.config import load_config_from_yaml from mkcd2app.target.install import install_target from mkcd2app.toolchains.install import install_toolchain +from mkcd2app.toolchains.uninstall import uninstall_toolchain from mkcd2app.utils.logger import create_logger, set_all_stdout_logger_levels from mkcd2app.utils.paths import ( get_redun_db_for_target_path, @@ -38,6 +39,12 @@ def main() -> None: elif args.toolchain_command == "uninstall": logger.debug("Uninstalling MakeCode CLI toolchain") + # noinspection none-function-assignment + run_redun_task( + expr=uninstall_toolchain(), + redun_db_path=get_redun_db_for_toolchain_path(), + ) + logger.debug("Toolchain uninstalled") elif args.command == "target": if args.target_command == "install": install_version: str = args.version diff --git a/src/mkcd2app/toolchains/install.py b/src/mkcd2app/toolchains/install.py index 2454c29..86415ec 100644 --- a/src/mkcd2app/toolchains/install.py +++ b/src/mkcd2app/toolchains/install.py @@ -8,7 +8,10 @@ from redun.file import ContentDir from mkcd2app.utils.logger import create_logger -from mkcd2app.utils.paths import get_js_tools_dir, get_templates_npm_cache_dir +from mkcd2app.utils.paths import ( + get_js_tools_dir, + get_templates_npm_cache_dir, +) from mkcd2app.utils.resources import ( get_resource_js_tools_path, get_resource_template_path, diff --git a/src/mkcd2app/toolchains/uninstall.py b/src/mkcd2app/toolchains/uninstall.py new file mode 100644 index 0000000..d6b1b62 --- /dev/null +++ b/src/mkcd2app/toolchains/uninstall.py @@ -0,0 +1,23 @@ +import logging + +from redun import task + +from mkcd2app.utils.filesystem import rmtree_robust +from mkcd2app.utils.logger import create_logger +from mkcd2app.utils.paths import get_toolchain_dir + +logger = create_logger(name=__name__, level=logging.INFO) + + +@task(namespace="mkcd2app") +def uninstall_toolchain() -> None: + """ + Uninstall the toolchain for this mkcd2app version by removing the toolchain + directory and its redun cache DB. + """ + logger.info("Uninstalling toolchain") + + toolchain_path = get_toolchain_dir() + logger.debug(f"Removing toolchain directory {toolchain_path}") + # Yes we want exception to crash, figure out nice message to print later + rmtree_robust(toolchain_path) diff --git a/src/mkcd2app/utils/filesystem.py b/src/mkcd2app/utils/filesystem.py index f0d6ab8..5a9d0b8 100644 --- a/src/mkcd2app/utils/filesystem.py +++ b/src/mkcd2app/utils/filesystem.py @@ -22,6 +22,4 @@ def _remove_readonly( def rmtree_robust(path: Path) -> None: - if not path.exists(): - return shutil.rmtree(path, onerror=_remove_readonly) From 1a095571daf7a79753349f4658b2c493a6b80f63 Mon Sep 17 00:00:00 2001 From: Cyrus Yiu Date: Mon, 3 Aug 2026 20:46:51 -0400 Subject: [PATCH 7/9] Target uninstall command --- src/mkcd2app/main.py | 11 +++++++-- src/mkcd2app/target/uninstall.py | 24 +++++++++++++++++++ .../{toolchains => toolchain}/__init__.py | 0 .../{toolchains => toolchain}/install.py | 1 + .../{toolchains => toolchain}/uninstall.py | 1 - src/mkcd2app/utils/paths.py | 6 ++++- 6 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 src/mkcd2app/target/uninstall.py rename src/mkcd2app/{toolchains => toolchain}/__init__.py (100%) rename src/mkcd2app/{toolchains => toolchain}/install.py (99%) rename src/mkcd2app/{toolchains => toolchain}/uninstall.py (89%) diff --git a/src/mkcd2app/main.py b/src/mkcd2app/main.py index 7742406..524c508 100644 --- a/src/mkcd2app/main.py +++ b/src/mkcd2app/main.py @@ -6,8 +6,9 @@ from mkcd2app.cli import generate_and_parse_args from mkcd2app.config import load_config_from_yaml from mkcd2app.target.install import install_target -from mkcd2app.toolchains.install import install_toolchain -from mkcd2app.toolchains.uninstall import uninstall_toolchain +from mkcd2app.target.uninstall import uninstall_target +from mkcd2app.toolchain.install import install_toolchain +from mkcd2app.toolchain.uninstall import uninstall_toolchain from mkcd2app.utils.logger import create_logger, set_all_stdout_logger_levels from mkcd2app.utils.paths import ( get_redun_db_for_target_path, @@ -64,6 +65,12 @@ def main() -> None: logger.debug( f"Uninstalling MakeCode CLI target version {uninstall_version}" ) + # noinspection none-function-assignment + run_redun_task( + expr=uninstall_target(uninstall_version), + redun_db_path=get_redun_db_for_target_path(), + ) + logger.debug(f"Target {uninstall_version} uninstalled") elif args.command == "build": config_path = Path(args.config) diff --git a/src/mkcd2app/target/uninstall.py b/src/mkcd2app/target/uninstall.py new file mode 100644 index 0000000..80c461c --- /dev/null +++ b/src/mkcd2app/target/uninstall.py @@ -0,0 +1,24 @@ +import logging + +from redun import task + +from mkcd2app.utils.filesystem import rmtree_robust +from mkcd2app.utils.logger import create_logger +from mkcd2app.utils.paths import get_target_version_dir + +logger = create_logger(name=__name__, level=logging.INFO) + + +@task(namespace="mkcd2app") +def uninstall_target(version: str) -> None: + """ + Uninstall a MakeCode Arcade target version for this mkcd2app version. + + :param version: The MakeCode Arcade version to target. E.g., "4.0.14". Must be + explicit 3 num sem ver, not just like "4.0" or "4", and do not include a "v". + """ + logger.info(f"Uninstall MakeCode Arcade target version {version}") + + target_path = get_target_version_dir(version) + logger.debug(f"Removing target directory {target_path}") + rmtree_robust(target_path) diff --git a/src/mkcd2app/toolchains/__init__.py b/src/mkcd2app/toolchain/__init__.py similarity index 100% rename from src/mkcd2app/toolchains/__init__.py rename to src/mkcd2app/toolchain/__init__.py diff --git a/src/mkcd2app/toolchains/install.py b/src/mkcd2app/toolchain/install.py similarity index 99% rename from src/mkcd2app/toolchains/install.py rename to src/mkcd2app/toolchain/install.py index 86415ec..b8b32b3 100644 --- a/src/mkcd2app/toolchains/install.py +++ b/src/mkcd2app/toolchain/install.py @@ -90,6 +90,7 @@ def warm_npm_cache_for_templates(templates: ContentDir) -> ContentDir: def install_toolchain() -> tuple[ContentDir, ContentDir]: """ Install the toolchain for this mkcd2app version. + :return: A tuple of two ContentDirs, where the first one is the node_modules folder for the JS tools, and the other one is the cache directory for the templates. """ diff --git a/src/mkcd2app/toolchains/uninstall.py b/src/mkcd2app/toolchain/uninstall.py similarity index 89% rename from src/mkcd2app/toolchains/uninstall.py rename to src/mkcd2app/toolchain/uninstall.py index d6b1b62..af77558 100644 --- a/src/mkcd2app/toolchains/uninstall.py +++ b/src/mkcd2app/toolchain/uninstall.py @@ -19,5 +19,4 @@ def uninstall_toolchain() -> None: toolchain_path = get_toolchain_dir() logger.debug(f"Removing toolchain directory {toolchain_path}") - # Yes we want exception to crash, figure out nice message to print later rmtree_robust(toolchain_path) diff --git a/src/mkcd2app/utils/paths.py b/src/mkcd2app/utils/paths.py index 6a86e41..aabf229 100644 --- a/src/mkcd2app/utils/paths.py +++ b/src/mkcd2app/utils/paths.py @@ -53,5 +53,9 @@ def get_redun_db_for_target_path() -> Path: return get_user_state_dir() / "redun_db_for_target.sqlite3" +def get_target_version_dir(v: str) -> Path: + return get_target_dir() / v + + def get_sim_html_path(v: str) -> Path: - return get_target_dir() / v / "---simulator.html" + return get_target_version_dir(v) / "---simulator.html" From 7158822637d129de651eef99dc6c8867c0b3a976 Mon Sep 17 00:00:00 2001 From: Cyrus Yiu Date: Mon, 3 Aug 2026 20:58:57 -0400 Subject: [PATCH 8/9] Remove unused install mkcd tools function, handled in toolchain setup --- src/mkcd2app/build_project/__init__.py | 30 -------------------------- 1 file changed, 30 deletions(-) diff --git a/src/mkcd2app/build_project/__init__.py b/src/mkcd2app/build_project/__init__.py index ed5b9c9..cf0a18c 100644 --- a/src/mkcd2app/build_project/__init__.py +++ b/src/mkcd2app/build_project/__init__.py @@ -1,5 +1,4 @@ import logging -import shutil from contextlib import ExitStack from dataclasses import dataclass from pathlib import Path @@ -22,39 +21,10 @@ from mkcd2app.models.config import StaticOutput, StaticSinglefileOutput from mkcd2app.utils.logger import create_logger from mkcd2app.utils.resources import get_resource_template_path -from mkcd2app.utils.run import run_cmd logger = create_logger(name=__name__, level=logging.INFO) -@task(namespace="mkcd2app") -def install_mkcd_build_tools(config_yaml: str, js_tools_src: ContentDir) -> ContentDir: - """ - Installs the MakeCode Arcade build tools. - - :param config_yaml: The raw YAML text of the config file. - :param js_tools_src: A redun.ContentDir pointing to the js_tools directory, - so that redun tracks changes to package.json etc. - :return: A redun.ContentDir that points to node_modules, this is only used so that - redun will see that some tasks depend on `mkc` being installed. - """ - logger.info("Installing MakeCode Arcade build tools") - - config = load_config_from_yaml(config_yaml) - build_path = Path(config.build_dir) - logger.debug(f"Tools will be installed in {build_path}") - - js_tools_path = Path(js_tools_src.path) - shutil.copy(js_tools_path / "package.json", build_path / "package.json") - shutil.copy(js_tools_path / "package-lock.json", build_path / "package-lock.json") - - run_cmd(["npm", "ci"], cwd=build_path) - - logger.debug("All MakeCode Arcade build tools installed") - - return ContentDir(str(build_path / "node_modules")) - - @dataclass class BuildProjectResult: static: ContentDir | None = None From 66378b14d72ce51a4cc3eecc00c20525f77e165f Mon Sep 17 00:00:00 2001 From: Cyrus Yiu Date: Mon, 3 Aug 2026 23:53:07 -0400 Subject: [PATCH 9/9] Actually use the template npm cache --- src/mkcd2app/build_project/inputs/code.py | 7 +++++++ src/mkcd2app/build_project/website.py | 11 +++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/mkcd2app/build_project/inputs/code.py b/src/mkcd2app/build_project/inputs/code.py index 9759623..29ac309 100644 --- a/src/mkcd2app/build_project/inputs/code.py +++ b/src/mkcd2app/build_project/inputs/code.py @@ -1,3 +1,4 @@ +import json import logging import shutil from io import BytesIO @@ -73,6 +74,12 @@ def fetch_code(config_yaml: str) -> ContentDir: logger.debug(f"Copying source code from {path}") shutil.copytree(path, code_path) + mkc_json_path = code_path / "mkc.json" + logger.debug("Writing mkc.json") + version = config.target + mkc_json = {"targetWebsite": f"https://arcade.makecode.com/v{version}"} + mkc_json_path.write_text(json.dumps(mkc_json)) + logger.debug("Source code downloaded") return ContentDir(str(code_path)) diff --git a/src/mkcd2app/build_project/website.py b/src/mkcd2app/build_project/website.py index 126865c..98d4055 100644 --- a/src/mkcd2app/build_project/website.py +++ b/src/mkcd2app/build_project/website.py @@ -9,6 +9,7 @@ from mkcd2app.config import load_config_from_yaml from mkcd2app.utils.logger import create_logger +from mkcd2app.utils.paths import get_templates_npm_cache_dir from mkcd2app.utils.run import run_cmd logger = create_logger(name=__name__, level=logging.INFO) @@ -143,7 +144,10 @@ def install_deps_and_build_website(website_filled_path: ContentDir) -> ContentDi shutil.rmtree(dst) shutil.copytree(src, dst) - run_cmd(["npm", "ci"], cwd=dst) + cache_path = get_templates_npm_cache_dir() + logger.debug(f"Using template npm cache at {cache_path} to install") + + run_cmd(["npm", "ci", "--cache", str(cache_path), "--offline"], cwd=dst) logger.debug("Website dependencies installed") run_cmd(["npm", "run", "build"], cwd=dst) @@ -180,7 +184,10 @@ def install_deps_and_build_website_singlefile( shutil.rmtree(dst) shutil.copytree(src, dst) - run_cmd(["npm", "ci"], cwd=dst) + cache_path = get_templates_npm_cache_dir() + logger.debug(f"Using template npm cache at {cache_path} to install") + + run_cmd(["npm", "ci", "--cache", str(cache_path), "--offline"], cwd=dst) logger.debug("Website dependencies installed") run_cmd(["npm", "run", "build:singlefile"], cwd=dst)