diff --git a/.gitignore b/.gitignore index 97073c5ade..098eec7610 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,12 @@ emsdk/ # daspkg artifacts daspkg.lock +.daspkg_global.lock +.daspkg_standalone +modules/.daspkg_tmp/ +modules/.daspkg_backup/ +modules/.daspkg_cache/ +modules/.daspkg.log examples/*/modules/ # Claude Code (local settings) diff --git a/CMakeLists.txt b/CMakeLists.txt index 0999ba39ab..72095e6db1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -400,7 +400,10 @@ ENDFUNCTION() FOREACH(_module ${_modules}) - INCLUDE(modules/${_module}/CMakeLists.txt OPTIONAL) + # Skip daspkg-installed modules that build themselves (standalone CMake) + IF(NOT EXISTS "${PROJECT_SOURCE_DIR}/modules/${_module}/.daspkg_standalone") + INCLUDE(modules/${_module}/CMakeLists.txt OPTIONAL) + ENDIF() ENDFOREACH() # Clean stale .shared_module files. When a module is disabled, its .shared_module @@ -1307,6 +1310,7 @@ install(FILES install(FILES ${PROJECT_SOURCE_DIR}/install/README.md DESTINATION ${DAS_INSTALL_DOCDIR}) install(FILES ${PROJECT_SOURCE_DIR}/install/CLAUDE.md DESTINATION ${DAS_INSTALL_DOCDIR}) +install(FILES ${PROJECT_SOURCE_DIR}/install/.gitignore DESTINATION ${DAS_INSTALL_DOCDIR}) install(DIRECTORY ${PROJECT_SOURCE_DIR}/install/skills DESTINATION ${DAS_INSTALL_DOCDIR}) install(FILES ${PROJECT_SOURCE_DIR}/LICENSE DESTINATION ${DAS_INSTALL_DOCDIR}) install(FILES ${PROJECT_SOURCE_DIR}/3rdparty/uriparser/COPYING DESTINATION ${DAS_INSTALL_DOCDIR} RENAME URIPARSER.LICENSE) @@ -1354,7 +1358,6 @@ file(GLOB DAS_DASPKG_FILES ${PROJECT_SOURCE_DIR}/utils/daspkg/*.das) install(FILES ${DAS_DASPKG_FILES} DESTINATION utils/daspkg) install(FILES ${PROJECT_SOURCE_DIR}/utils/daspkg/README.md - ${PROJECT_SOURCE_DIR}/utils/daspkg/DESIGN.md DESTINATION utils/daspkg ) install(DIRECTORY ${PROJECT_SOURCE_DIR}/utils/daspkg/fixtures/ diff --git a/doc/source/reference/utils/daspkg.rst b/doc/source/reference/utils/daspkg.rst index 4015eb9b95..4eed284544 100644 --- a/doc/source/reference/utils/daspkg.rst +++ b/doc/source/reference/utils/daspkg.rst @@ -59,9 +59,10 @@ Design principles: - **Executable manifests** -- ``.das_package`` is a daslang script, not a static JSON file. Version resolution, dependency declarations, and build steps can contain arbitrary logic. -- **Per-project** -- packages install into the project's ``modules/`` - directory (like ``node_modules``), not globally. Reproducible builds - by default. +- **Per-project by default** -- packages install into the project's + ``modules/`` directory (like ``node_modules``). Reproducible builds + by default. Large modules can be installed **globally** with + ``--global`` to avoid redundant clones and builds across projects. - **Two package types** -- pure-daslang (just ``.das`` files, no build) and C++ (cmake auto-build from source). @@ -84,6 +85,8 @@ Commands * - ``update [name]`` - Re-install one or all packages at their pinned version (re-clone, rebuild). + * - ``upgrade [name]`` + - Upgrade one or all packages to the latest version. * - ``list`` - List installed packages. * - ``search `` @@ -99,12 +102,21 @@ Commands * - ``withdraw `` - Remove a package from the index via PR (requires ``gh`` CLI). +All commands that operate on packages (``install``, ``remove``, +``update``, ``upgrade``, ``list``, ``check``, ``build``) accept the +``--global`` flag. + Options: - ``--root `` -- project root (default: current directory). - ``--force`` -- force reinstall even if already installed. +- ``--global``, ``-g`` -- operate on global modules in + ``{das_root}/modules/`` (see :ref:`daspkg_global_modules`). +- ``--color`` / ``--no-color`` -- enable/disable ANSI colored output. - ``--verbose``, ``-v`` -- print debug details (git commands, resolve steps, file operations). +- ``--json`` -- machine-readable JSON output (``search``, ``list``, + ``check``). Package sources @@ -251,6 +263,78 @@ When you run ``daspkg install github.com/user/repo@v1.0``: 7. Auto-build if ``.das_package`` has ``build()``. +.. _daspkg_global_modules: + +Global modules +============== + +By default, packages install per-project into ``{root}/modules/``. +Large packages with native builds (e.g. dasImgui) can be installed +**globally** -- once under ``{das_root}/modules/`` -- and shared across +all projects using that daScript SDK. + +.. code-block:: bash + + # install globally + daspkg install --global dasImgui + + # list / update / upgrade / remove / build / check globally + daspkg list --global + daspkg update --global dasImgui + daspkg upgrade --global dasImgui + daspkg remove --global dasImgui + daspkg build --global + daspkg check --global + +Install behavior +---------------- + +- **Global install** (``--global``): clones and builds in + ``{das_root}/modules/``, records in + ``{das_root}/modules/.daspkg_global.lock``. +- **Local install auto-uses global**: ``daspkg install foo`` checks the + global lock file first. If a compatible global version exists, it + records a reference (``"global": true`` in the project lock file) + instead of cloning -- zero network, zero build time. +- **Version mismatch**: if the global version doesn't satisfy the + requested version, daspkg errors with a suggestion. Use ``--force`` + to install locally, or ``--global`` to update the global copy. +- **Dependencies**: global packages' dependencies also install globally. + Built-in SDK modules already in ``{das_root}/modules/`` are skipped + automatically. + +Coexistence (local + global) +---------------------------- + +A package can exist both locally and globally. The C++ runtime +(``require_dynamic_modules``) handles this via **shadow detection**: + +- If the same module directory exists in both ``{das_root}/modules/`` + and ``{project_root}/modules/``, the **local version wins**. +- A warning is printed: + ``Warning: local 'dasImgui' shadows global -- using local`` +- This is safe -- removing the local copy seamlessly falls back to the + global one. + +Remove behavior +--------------- + +- ``daspkg remove --global foo`` -- deletes ``{das_root}/modules/foo/`` + and removes from global lock file. +- ``daspkg remove foo`` (where project entry has ``"global": true``) -- + only removes the project lock file reference; the global directory is + **not** deleted. + +CMake integration +----------------- + +Global packages that use ``cmake_build()`` or ``custom_build()`` get a +``.daspkg_standalone`` marker file. The main daScript ``CMakeLists.txt`` +skips directories with this marker during auto-discovery, preventing +build errors from standalone CMakeLists.txt files that expect +``DASLANG_DIR`` to be set explicitly. + + Project layout ============== @@ -270,6 +354,13 @@ Project layout .daspkg_cache/ # index cache (gitignored) .daspkg_tmp/ # temp dir during install (gitignored) + {das_root}/ # daScript SDK root + modules/ + .daspkg_global.lock # global lock file (gitignored) + / + .daspkg_standalone # marker: built by daspkg, skip in CMake + ... # same structure as local packages + Lock file ========= @@ -280,16 +371,18 @@ Lock file { "sdk_version": "", - "packages": { - "mymodule": { + "packages": [ + { + "name": "mymodule", "source": "github.com/user/mymodule", - "version": "v1.0", + "version": "1.0", "tag": "v1.0", "branch": "", "root": true, - "local": false + "local": false, + "global": false } - } + ] } - **root** -- ``true`` if the user explicitly installed this package; @@ -297,6 +390,11 @@ Lock file - **version** -- what the user requested. - **tag/branch** -- what ``.das_package`` resolved to (the actual git ref). - **local** -- ``true`` for packages installed from a local path. +- **global** -- ``true`` if the package is resolved from a global + install in ``{das_root}/modules/`` (no local copy). + +The global lock file (``{das_root}/modules/.daspkg_global.lock``) uses +the same format to track globally installed packages. Package index diff --git a/install/.gitignore b/install/.gitignore new file mode 100644 index 0000000000..16093c424b --- /dev/null +++ b/install/.gitignore @@ -0,0 +1,27 @@ +# Build artifacts +build/ +bin/ +lib/ +.cache/ + +# JIT +.jitted_scripts/ + +# AOT +_aot_generated/ + +# daspkg +daspkg.lock +.daspkg_global.lock +.daspkg_standalone +modules/.daspkg_tmp/ +modules/.daspkg_backup/ +modules/.daspkg_cache/ +modules/.daspkg.log + +# IDE +.vscode/ + +# MCP +.mcp.json +sgconfig.yml diff --git a/skills/daspkg.md b/skills/daspkg.md index f195991495..bcea75ae37 100644 --- a/skills/daspkg.md +++ b/skills/daspkg.md @@ -20,14 +20,14 @@ The `--root` flag sets the project root directory (default: current directory). | Command | Usage | Description | |---|---|---| -| `search` | `search [query] --json` | Search the package index. Empty query lists all. `--json` for structured output | -| `install` | `install [--force]` | Install a package. Without source, installs all deps from `.das_package` | -| `remove` | `remove ` | Remove an installed package | -| `update` | `update [name]` | Re-install at pinned version. Without name, updates all | -| `upgrade` | `upgrade [name]` | Upgrade to latest version. Without name, upgrades all | -| `list` | `list [--json]` | List installed packages | -| `check` | `check [--json]` | Verify installed packages match lockfile | -| `build` | `build` | Build native (CMake) packages | +| `search` | `search [query] [--json]` | Search the package index. Empty query lists all | +| `install` | `install [--force] [--global]` | Install a package. Without source, installs all deps from `.das_package` | +| `remove` | `remove [--global]` | Remove an installed package | +| `update` | `update [name] [--global]` | Re-install at pinned version. Without name, updates all | +| `upgrade` | `upgrade [name] [--global]` | Upgrade to latest version. Without name, upgrades all | +| `list` | `list [--json] [--global]` | List installed packages | +| `check` | `check [--json] [--global]` | Verify installed packages match lockfile | +| `build` | `build [--global]` | Build native (CMake) packages | | `doctor` | `doctor` | Check environment (git, cmake, gh) | | `introduce` | `introduce` | Register package in the public index (creates PR on daspkg-index) | | `withdraw` | `withdraw` | Remove package from the public index | @@ -39,14 +39,76 @@ The `--root` flag sets the project root directory (default: current directory). - **Local path:** `install ./path/to/package` - **Index name:** `install my-package` (looks up in package index) +## Options + +| Flag | Description | +|---|---| +| `--root ` | Project root directory (default: current directory) | +| `--force` | Force reinstall (overrides duplicate/version checks) | +| `--global`, `-g` | Operate on global modules in `{das_root}/modules/` (see below) | +| `--color` | Enable colored output | +| `--no-color` | Disable colored output (useful for capturing output) | +| `--verbose`, `-v` | Print detailed progress | +| `--json` | Machine-readable JSON output (`search`, `list`, `check`) | + ## Key Details -- Packages install to `modules//` (e.g. `modules/dasAnthropic/`) +- Packages install to `{root}/modules//` (e.g. `modules/dasAnthropic/`) - Lock file: `daspkg.lock` in the `--root` directory - Package name (in `.das_package`) can differ from repo name - `install` and `update`/`upgrade` can take 10+ minutes for packages with native builds — use long timeouts -- `--no-color` flag disables ANSI color output (useful for capturing output) -- `--json` flag on `search`, `list`, `check` returns structured JSON + +## Global Modules + +Large packages (e.g. dasImgui) can be installed **globally** — once under `{das_root}/modules/` — and shared across all projects using that daScript SDK. This avoids redundant clones and builds. + +### Usage + +```bash +# Install globally (to das_root/modules/) +daspkg install --global dasImgui +daspkg install --global github.com/user/dasImgui@1.0 + +# List globally installed packages +daspkg list --global + +# Update/upgrade globally +daspkg update --global dasImgui +daspkg upgrade --global dasImgui + +# Remove globally +daspkg remove --global dasImgui + +# Build all global native packages +daspkg build --global + +# Check global packages +daspkg check --global +``` + +### Install behavior + +- **Global install** (`--global`): clones and builds in `{das_root}/modules/`, records in `{das_root}/modules/.daspkg_global.lock` +- **Local install auto-uses global:** `daspkg install foo` checks the global lock file first. If a compatible global version exists, it records a reference (`"global": true` in project lock file) instead of cloning — zero network, zero build time +- **Version mismatch:** if the global version doesn't satisfy the project's requested version, daspkg errors with a suggestion. Use `--force` to install locally, or `--global` to update the global copy +- **Dependencies:** global packages' dependencies also install globally. Built-in SDK modules (already in `das_root/modules/`) are automatically skipped + +### Coexistence (local + global) + +A package can exist both locally and globally. The C++ runtime (`require_dynamic_modules`) handles this via **shadow detection**: + +- If the same module directory exists in both `{das_root}/modules/` and `{project_root}/modules/`, the **local version wins** +- A warning is printed: `"Warning: local 'dasImgui' shadows global — using local"` +- This is safe — removing the local copy seamlessly falls back to the global one + +### Remove behavior + +- `daspkg remove --global foo` — deletes `{das_root}/modules/foo/` and removes from global lock file +- `daspkg remove foo` (where project entry has `"global": true`) — only removes the project lock file reference; the global directory is not deleted + +### CMake integration + +Global packages that use `cmake_build()` or `custom_build()` get a `.daspkg_standalone` marker file. The main daScript `CMakeLists.txt` skips directories with this marker during auto-discovery, preventing `FATAL_ERROR` from standalone CMakeLists.txt files (e.g. dasImgui requires `DASLANG_DIR` to be set explicitly). ## `.das_package` Manifest diff --git a/src/ast/dyn_modules.cpp b/src/ast/dyn_modules.cpp index 16f49bf99e..5d7f974bd2 100644 --- a/src/ast/dyn_modules.cpp +++ b/src/ast/dyn_modules.cpp @@ -83,7 +83,45 @@ static Result init_dyn_modules(smart_ptr fa, string path, TextWriter } } -static bool init_modules_for_folder(FileAccessPtr fa, const das::string &path, das::TextWriter &tout) { +// Collect directory names under path/modules/ without loading anything +static das_hash_set collect_module_names(const das::string &path) { + das_hash_set result; +#if DAS_NO_FILEIO + return result; +#else + das::string modules_path = path + "/modules/"; +#if defined(_MSC_VER) + _finddata_t c_file; + intptr_t hFile; + das::string findPath = modules_path + "/*"; + if ((hFile = _findfirst(findPath.c_str(), &c_file)) != -1L) { + do { + if (c_file.name[0] == '.') { + continue; + } + result.insert(das::string(c_file.name)); + } while (_findnext(hFile, &c_file) == 0); + _findclose(hFile); + } +#else + DIR *dir; + struct dirent *ent; + if ((dir = opendir(modules_path.c_str())) != NULL) { + while ((ent = readdir(dir)) != NULL) { + if (ent->d_name[0] == '.') { + continue; + } + result.insert(das::string(ent->d_name)); + } + closedir(dir); + } +#endif + return result; +#endif // DAS_NO_FILEIO +} + +static bool init_modules_for_folder(FileAccessPtr fa, const das::string &path, das::TextWriter &tout, + const das_hash_set *skip_set = nullptr) { // FileAccess do not support iteratinf over directory. #if DAS_NO_FILEIO return false; @@ -100,6 +138,10 @@ static bool init_modules_for_folder(FileAccessPtr fa, const das::string &path, d if (strcmp(c_file.name, ".") == 0 || strcmp(c_file.name, "..") == 0) { continue; } + if (skip_set && skip_set->count(das::string(c_file.name))) { + tout << "Warning: local '" << c_file.name << "' shadows global — using local\n"; + continue; + } all_good &= Result::OK == init_dyn_modules(fa, modules_path + c_file.name, tout, false); } while (_findnext(hFile, &c_file) == 0); } @@ -112,6 +154,10 @@ static bool init_modules_for_folder(FileAccessPtr fa, const das::string &path, d if (strcmp(ent->d_name, ".") == 0 || strcmp(ent->d_name, "..") == 0) { continue; } + if (skip_set && skip_set->count(das::string(ent->d_name))) { + tout << "Warning: local '" << ent->d_name << "' shadows global — using local\n"; + continue; + } all_good &= Result::OK == init_dyn_modules(fa, modules_path + ent->d_name, tout, false); } closedir (dir); @@ -125,12 +171,19 @@ bool require_dynamic_modules(FileAccessPtr file_access, const das::string &das_root, const das::string &project_root, das::TextWriter &tout) { - // Always init for dasroot. - bool all_good = das::init_modules_for_folder(file_access, das_root, tout); - if (!project_root.empty() && + bool has_project = !project_root.empty() && normalizeFileName(das_root.c_str()) != - normalizeFileName(project_root.c_str())) { - // Init for project_root. + normalizeFileName(project_root.c_str()); + // Collect local module names first so we can skip shadows in das_root + das_hash_set local_names; + if (has_project) { + local_names = collect_module_names(project_root); + } + // Always init for dasroot, skipping names that exist locally + bool all_good = das::init_modules_for_folder(file_access, das_root, tout, + local_names.empty() ? nullptr : &local_names); + if (has_project) { + // Init for project_root (no skipping) all_good &= das::init_modules_for_folder(file_access, project_root, tout); } return all_good; diff --git a/src/builtin/module_jit.cpp b/src/builtin/module_jit.cpp index 1daab36db4..948edd57cc 100644 --- a/src/builtin/module_jit.cpp +++ b/src/builtin/module_jit.cpp @@ -640,25 +640,22 @@ extern "C" { } extern "C" { - void * get_jit_table_find ( int32_t baseType, Context * context, LineInfoArg * at ) { + DAS_API void * get_jit_table_find ( int32_t baseType, Context * context, LineInfoArg * at ) { return das_get_jit_table_find(baseType, context, at); } - void * get_jit_table_at ( int32_t baseType, Context * context, LineInfoArg * at ) { + DAS_API void * get_jit_table_at ( int32_t baseType, Context * context, LineInfoArg * at ) { return das_get_jit_table_at(baseType, context, at); } - void * get_jit_table_erase ( int32_t baseType, Context * context, LineInfoArg * at ) { + DAS_API void * get_jit_table_erase ( int32_t baseType, Context * context, LineInfoArg * at ) { return das_get_jit_table_erase(baseType, context, at); } - - void * das_get_jit_new ( TypeAnnotation *annotation ) { + DAS_API void * das_get_jit_new ( TypeAnnotation *annotation ) { return annotation->jitGetNew(); } - - void * das_get_jit_delete ( TypeAnnotation *annotation ) { + DAS_API void * das_get_jit_delete ( TypeAnnotation *annotation ) { return annotation->jitGetDelete(); } - - void * das_get_jit_clone ( TypeAnnotation *annotation ) { + DAS_API void * das_get_jit_clone ( TypeAnnotation *annotation ) { return annotation->jitGetClone(); } } diff --git a/utils/daScript/main.cpp b/utils/daScript/main.cpp index 98cb403ce9..dbba0a9087 100644 --- a/utils/daScript/main.cpp +++ b/utils/daScript/main.cpp @@ -515,19 +515,24 @@ void print_help() { tout << "daslang version " << DAS_VERSION_MAJOR << "." << DAS_VERSION_MINOR << "." << DAS_VERSION_PATCH << "\n" << "daslang scriptName1 {scriptName2} .. {-main mainFnName} {-log} {-pause} -- {script arguments}\n" + << " -main set entry function name (default: main)\n" << " -v2syntax enable version 2 syntax (uses braces {} for code blocks) [default]\n" << " -v1syntax enable version 1 syntax (uses Python-style indentation for code blocks)\n" << " -v2makeSyntax enable version 1 syntax with version 2 constructors syntax (for arrays/structures)\n" << " -jit enable Just-In-Time compilation\n" + << " -exe JIT compile to standalone executable (implies -dry-run)\n" + << " -output set JIT output path\n" << " -use-aot enable AOT linking (requires AOT stubs linked into the binary)\n" + << " -aot2 AOT generation (v2, implies -dry-run)\n" + << " -aot_lib mark AOT output as library module (use with -aot2)\n" << " -project path to project file\n" - << " -project_root optional path to root directory of the project (used for dyn modules)\n" - << " -run-fmt run formatter, requires 2 or more arguments\n" + << " -project_root root directory of the project (used for dyn modules)\n" + << " -run-fmt <-i/-d> <-v2/-v1> {--semicolon} run formatter\n" << " -log output program code\n" << " -pause pause after errors and pause again before exiting program\n" << " -dry-run compile and simulate script without execution\n" << " -compile-only compile script without simulation and execution\n" - << " -dasroot set path to daslang root folder (with daslib)\n" + << " -dasroot set path to daslang root folder (with daslib)\n" #if DAS_SMART_PTR_ID << " -track-smart-ptr track smart pointer with id\n" #endif @@ -538,12 +543,13 @@ void print_help() { << " -das-profiler-manual manual profiler control\n" << " -das-profiler-memory memory profiler\n" << " -no-dynamic-modules skip loading dynamic modules from dasroot and project root\n" + << " -- separator for script arguments\n" << "daslang -aot {-q} {-p}\n" << " -project path to project file\n" << " -p paranoid validation of CPP AOT\n" << " -q suppress all output\n" << " -dry-run no changes will be written\n" - << " -dasroot set path to daslang root folder (with daslib)\n" + << " -dasroot set path to daslang root folder (with daslib)\n" ; } @@ -754,6 +760,9 @@ int MAIN_FUNC_NAME ( int argc, char * argv[] ) { // do nohting, script handles it } else if ( cmd=="no-dynamic-modules" ) { noDynamicModules = true; + } else if ( cmd=="h" || cmd=="-help" ) { + print_help(); + return 0; } else if ( !scriptArgs) { printf("unknown command line option %s\n", cmd.c_str()); print_help(); diff --git a/utils/daslang-live/main.cpp b/utils/daslang-live/main.cpp index bd0ec1bea8..b722d222a0 100644 --- a/utils/daslang-live/main.cpp +++ b/utils/daslang-live/main.cpp @@ -525,11 +525,13 @@ static int run_lifecycle(const string & fn) { static void print_help() { tout << "daslang-live — live-reloading application host for daScript\n"; - tout << "Usage: daslang-live [options] \n"; + tout << "Usage: daslang-live [options] [-- script arguments]\n"; tout << " -project — project file (.das_project)\n"; tout << " -dasroot — override DAS_ROOT\n"; tout << " -cwd — change working directory to script's folder\n"; tout << " -v1syntax — use v1 syntax (default: v2)\n"; + tout << " --no-dyn-modules — skip loading dynamic modules\n"; + tout << " -- — separator for script arguments\n"; tout << " -h, --help — this help\n"; } diff --git a/utils/daspkg/DESIGN.md b/utils/daspkg/DESIGN.md deleted file mode 100644 index 23c818e667..0000000000 --- a/utils/daspkg/DESIGN.md +++ /dev/null @@ -1,354 +0,0 @@ -# daspkg — daslang Package Manager - -## Goals - -Package manager for the daslang ecosystem. Automates discovery, installation, dependency resolution, and building of daslang packages (both pure-das and C++ modules). - -## Package Types - -### Pure-das packages -- Just `.das` files + `.das_module` (provided by author), no build step -- Package manager downloads and places in `modules/` -- Works for everyone — no toolchain required - -### C++ packages -- Contains C++ source + `CMakeLists.txt` using `find_package(DAS)` -- Package manager runs `cmake --configure` + `cmake --build` automatically -- Requires C++ toolchain (MSVC on Windows, GCC/Clang on Linux/Mac) -- Fails gracefully if toolchain is missing -- No prebuilt binary hosting — always build from source - -## Version Model - -Three version axes: -- **Package version** — semver of the package itself -- **daslang version** — which SDK release the package is compatible with -- **Dependencies** — other packages (with their own version constraints) - -The manifest can use `sdk_version` in `resolve()` to return different tags for different SDK versions. - -C++ packages are sensitive to the exact daslang version (ABI compatibility). Pure-das packages are more tolerant (only language-level compatibility). - -## Key Design Decisions - -### Registry: Git-based (like Go modules) + curated index -- Packages are git repositories (GitHub, GitLab, etc.) -- No centralized registry server to maintain -- Install by URL: `daspkg install github.com/user/repo` -- Version = git tag (e.g., `v1.0.0`) - -### Package index -- JSON array in a dedicated index repo ([borisbat/daspkg-index](https://github.com/borisbat/daspkg-index)) -- Each entry: `{ "name": "...", "url": "...", "description": "..." }` -- Authors add packages via `daspkg introduce`, which creates a PR -- `daspkg search ` clones/fetches the index repo, matches against it. Cached in `modules/.daspkg_cache/index/` -- Install by name (from index): `daspkg install imgui` -- Install by URL (direct): `daspkg install github.com/user/dasImgui` - -### Manifest: `.das_package` (executable) - -The manifest is a daslang script — same pattern as `.das_module`. daspkg compiles it in-process (`package_runner.das`), calls exported functions, and reads accumulated state from `daslib/daspkg` module globals. - -Each function answers a specific question. All are optional — a repo without `.das_package` gets a "dumb clone" (no version resolution, no deps, no build). - -```das -options gen2 -require daslib/daspkg - -// Package metadata -[export] -def package() { - package_name("mymodule") - package_author("aleksisch") - package_description("Cool module for daslang") - package_source("https://github.com/aleksisch/mymodule") -} - -// What to download for a given version + SDK -[export] -def resolve(sdk_version : string; version : string) { - if (version == "" || version == "latest") - download_tag("v2.1.0") - elif (version == "1.x") - download_tag("v1.5.3") - else - download_tag("v{version}") - // alternatives: download_branch("main"), download_redirect("github.com/other/repo", "v1.0") -} - -// What other packages are needed -[export] -def dependencies(version : string) { - require_package("github.com/user/some_lib", ">=1.0") - require_package("github.com/user/other_lib") -} - -// How to build (if missing, assumes pure-das — no build step) -[export] -def build(sdk_path : string; version : string) { - cmake_build() // default: cmake configure + build with -DDASLANG_DIR= - // alternatives: no_build(), custom_build("make all") -} -``` - -**Why executable, not JSON?** -- Authors can put version-conditional logic in `resolve` and `dependencies` - (e.g., "for SDK >= 0.6 use this tag, for older use that one") -- Same pattern as `.das_module` — familiar to daslang authors -- daspkg provides the registration functions (`package_name`, `download_tag`, etc.) via `daslib/daspkg` - -Module registration (namespaces, DLLs, require paths) is handled entirely by `.das_module` — not duplicated in `.das_package`. - -### Local layout - -daspkg installs into `modules/` relative to the project root. The daslang runtime already scans `modules/` for `.das_module` files — first in the SDK, then in the app's directory. No new discovery mechanism needed. - -``` -my_project/ - main.das - .das_package # project manifest (optional — lists dependencies) - modules/ # standard daslang modules directory - / - .das_module # provided by package author - .das_package # package manifest - _build/ # CMake build directory (if C++ package) - *.shared_module # built C++ output (if applicable) - *.das # source files - .daspkg_cache/ # index cache (gitignored) - .daspkg_tmp/ # temp dir during install (gitignored) - daspkg.lock # JSON — installed packages, versions, sources -``` - -Packages are **per-project** (like `node_modules`), which is the right default for game projects that need reproducible builds. The SDK's own `modules/` has the built-in modules; the project's `modules/` has third-party packages. - -### Package naming -- Package name = what `package_name()` declares in `.das_package`. Must be unique. -- Directory in `modules/` uses the package name (not the git repo name) -- The curated index enforces uniqueness — PR review catches collisions -- **Module namespace conflicts** (two packages registering the same `require` path) are a runtime issue, not a daspkg issue. Authors must pick unique namespaces. - -### Repos without `.das_package` -If a repo has no `.das_package`, daspkg does a "dumb clone" — clones the repo into `modules//` at the requested tag (or HEAD). No version resolution, no dependency tracking, no build. The repo must already have a `.das_module` and be self-contained. Recorded in the lock file with minimal info. - -### `.das_module` handling -- `.das_module` is always provided by the package author — not generated by daspkg -- The author knows their DLL names, `require` namespaces, load dependencies -- daspkg just places the package in `modules//` and the existing `.das_module` works as-is - -### Package sources - -Packages can come from three sources: - -1. **Git URL**: `daspkg install github.com/user/repo[@version]` — clones the repo -2. **Index name**: `daspkg install imgui` — looks up URL in the curated index -3. **Local path**: `daspkg install /path/to/my/module` — copies from a local directory - -Local paths are essential for development — you're working on a package and want to test it without pushing to git. Currently always copies; symlink support planned. - -## Commands - -``` -daspkg install [@version] Install from git, index, or local path (--force to reinstall) -daspkg install Install all deps from project .das_package -daspkg remove Uninstall a package -daspkg update [name] Re-install at pinned version (re-clone, rebuild) -daspkg upgrade [name] Upgrade to latest version (re-resolve, compare, re-install) -daspkg list List installed packages -daspkg search Search the package index -daspkg introduce [url] Add a package to the index via PR (requires gh) -daspkg withdraw Remove a package from the index via PR (requires gh) -daspkg build Build all C++ packages in modules/ -daspkg check Verify all packages are present and have .das_module -daspkg doctor Check environment (git, cmake, gh) -``` - -**Options:** -- `--root ` — project root (default: current directory) -- `--force` — force reinstall even if already installed -- `--verbose`, `-v` — print debug details to screen (git commands, resolve steps, file operations) - -### Logging - -All operational output goes to both screen and `modules/.daspkg.log`. Debug-level messages (git commands, resolve results, file moves) always go to the log file; they only appear on screen with `--verbose`. The log file appends across runs — useful for diagnosing issues after the fact. - -## Transport - -Only external dependency: **`git` CLI**. No HTTP library, no curl, no dasHV. - -### Git clone flow (current) - -Full shallow clone, then checkout tag/branch if needed. - -**Install:** -1. `git clone --depth 1 modules/.daspkg_tmp/` -2. Run `.das_package` → `resolve(sdk_version, version)` → get tag/branch, `dependencies()` → get deps -3. If tag/branch specified: `git fetch --depth 1 origin tag ` then `git checkout ` -4. If redirect: re-clone from the redirect URL -5. Move from `.daspkg_tmp/` to `modules//` -6. Record in lock file -7. Install dependencies (recursive) -8. Build if C++ (auto-detected from `.das_package` or `CMakeLists.txt`) - -**Update:** re-install at the pinned version from the lock file. Deletes existing, re-clones. - -### Local paths -- Filesystem copy, no git involved -- `.das_package` read directly from the local directory - -## Architecture - -### Modules - -- `main.das` — CLI entry point, argument parsing, command dispatch -- `commands.das` — all command implementations (install, remove, update, build, check, doctor) -- `lockfile.das` — `LockFile` / `PackageEntry` structs, JSON serialization -- `index.das` — package index fetch/search, introduce/withdraw PR workflow -- `utils.das` — `run_cmd`, `copy_dir_rec`, path utilities -- `package_runner.das` — in-process `.das_package` compiler and runner -- `daslib/daspkg.das` — API module that `.das_package` scripts `require` - -### Package runner - -`package_runner.das` compiles `.das_package` scripts in-process using `compile_file` + `simulate` + `invoke_in_context`. It calls specific exported functions (`package`, `resolve`, `dependencies`, `build`) and reads accumulated state from `daslib/daspkg` module globals via `get_context_global_variable`. - -This avoids spawning a subprocess — the `.das_package` runs in the same daslang process, just in a separate context. - -## Version Resolution - -### v1: Simple, no constraint solver - -**Install with version**: `daspkg install foo@1.2.0` → runs `resolve(sdk_version, "1.2.0")` → gets a git tag → clones it. - -**Install without version**: `daspkg install foo` → runs `resolve(sdk_version, "")` → `.das_package` decides what "latest" means. - -**Update**: `daspkg update foo` → re-clones at the same pinned version from the lock file. Re-runs `.das_package` resolve, rebuilds. Useful for picking up `.das_package` changes or rebuilding after SDK upgrade. - -### Lock file (`daspkg.lock`) - -Ordered JSON array — preserves installation order for sequential upgrades. - -```json -{ - "sdk_version": "", - "packages": [ - { - "name": "dasVulkan", - "source": "github.com/user/dasVulkan", - "version": "1.2.0", - "tag": "v1.2.0", - "branch": "", - "root": true, - "local": false - }, - { - "name": "dasUtils", - "source": "github.com/user/dasUtils", - "version": "", - "tag": "v0.3.1", - "branch": "", - "root": false, - "local": false - }, - { - "name": "myLocalMod", - "source": "/home/me/dev/myLocalMod", - "version": "", - "tag": "", - "branch": "", - "root": true, - "local": true - } - ] -} -``` - -- **`root: true`** — user explicitly installed this package -- **`root: false`** — pulled in as a transitive dependency -- **`version`** — what the user requested (e.g., `1.2.0` from `install foo@1.2.0`) -- **`tag` / `branch`** — what `.das_package` resolved to (the actual git ref) - -### Diamond dependencies -Only one version of a package can be installed — daslang has a single `require` namespace, no way to load two versions simultaneously (unlike npm's nested `node_modules`). - -**v1 rule:** first installed wins. If C@1.0 is already installed and package B wants C>=2.0, daspkg skips it (already installed). User can force-reinstall a specific version. - -Similar to Go modules — one version per module, manual upgrade. Works fine for small ecosystems. - -## C++ Build - -Build directory: `modules//_build/` — inside the package directory. Can be safely deleted and rebuilt with `daspkg build`. - -``` -cmake -B modules//_build -S modules/ -DDASLANG_DIR= -cmake --build modules//_build --config Release -``` - -The package's `CMakeLists.txt` is responsible for placing output (`.shared_module`) into its own source directory. daspkg just runs cmake. - -`_build/` and `.daspkg_cache/` should be in `.gitignore`. - -## Upgrade - -**`upgrade [name]`** — bump to latest available version. - -- Re-resolves with empty version string (`.das_package` decides "latest") -- Compares resolved tag/branch to installed — skips if already at latest -- Walks dependency graph downward: checks constraints, upgrades deps that don't satisfy their parent's `require_package` constraint -- `upgrade` (no args) — upgrades all root, non-local packages - -**Version constraints** in `require_package(url, constraint)`: -- Operators: `>=`, `>`, `<=`, `<`, `=` (or bare version for exact match) -- Comma-separated AND: `">=1.0,<2.0"` -- Semver parsing: strips `v` prefix, handles `major`, `major.minor`, `major.minor.patch` -- Implementation: `satisfies_constraint()` in `utils.das` - -## Not Yet Implemented - -### Discovery & Index - -Current state: curated `packages.json` in a git repo, `daspkg search` does substring match, `introduce`/`withdraw` create PRs. Closest to Go modules (decentralized, git-based, no auth). - -Comparison with other ecosystems: - -| | npm | Go | Rust (crates.io) | daspkg | -|---|---|---|---|---| -| Registry | Central server, web UI, API | No registry — any git URL works | Central server, web UI, API | Git repo + JSON file | -| Discovery | npmjs.com, download counts, categories | pkg.go.dev auto-indexes from proxy | crates.io, categories, badges | `search` (substring match) | -| Publishing | `npm publish` (auth token) | Push tagged commit | `cargo publish` (auth token) | `introduce` (creates PR) | -| Metadata | `package.json` (keywords, license, homepage) | `go.mod` (minimal) | `Cargo.toml` (categories, keywords) | `.das_package` (name, description, deps) | - -Planned improvements (ordered by effort): - -- **Richer index metadata** — add `keywords`, `license`, `author`, `tags` to index entries. Makes `search` more useful. Low effort — extend `IndexEntry` struct and `packages.json` schema. -- **Static catalog website** — generate GitHub Pages from `packages.json`. Browsable in a browser, no server. Medium effort. -- **Auto-discovery** — if `daspkg install github.com/user/repo` succeeds and the repo has a valid `.das_package`, offer to auto-add to index (or auto-index without PR for a "known packages" list). Reduces friction for publishing. -- **Search API / server** — full-text search, download counts, web UI. Only worth it with significant community. High effort. - -The PR-based introduce/withdraw is good for a young ecosystem — transparent, no infrastructure, maintainer review. Keep it as the primary path. - -### Transport & Performance - -- **Sparse checkout** — fetch only `.das_package` before downloading the full repo. Would reduce bandwidth for version resolution. - -### Install & Update - -- **Symlinks for local paths** — `--link` flag for local installs. Currently always copies. -- **Orphan cleanup** — detect unused transitive deps after `remove`. -- **SDK version tracking** — record SDK version in lock file, warn on mismatch, prompt `daspkg build` after SDK upgrade. - -### Security & Sandboxing - -- **`.das_project` sandbox** — restrict what `.das_package` scripts can `require` (no `fio`, no `unsafe`). Currently `.das_package` has full access. - -### Dependency Resolution - -- **Full constraint solver** — for when the ecosystem is large enough to need it (currently first-installed wins for diamonds). - -## Open Questions - -- [x] ~~Should packages live inside the SDK or per-project?~~ Per-project `modules/` — runtime already scans it -- [x] ~~Package tests~~ — not a daspkg concern. Authors put tests in `modules//tests/`. dastest can learn to glob `modules/*/tests/*.das`. -- [x] ~~Index/discovery~~ — single curated file in index repo, authors add via `daspkg introduce` -- [x] ~~Should `upgrade` be a separate command?~~ Yes — `update` re-installs at pinned version, `upgrade` bumps to latest -- [x] ~~Version constraint syntax~~ — semver ranges with operators (`>=1.0,<2.0`), implemented in `utils.das` -- [ ] Should local installs default to symlink or copy? diff --git a/utils/daspkg/README.md b/utils/daspkg/README.md index 049b446544..9bd021866c 100644 --- a/utils/daspkg/README.md +++ b/utils/daspkg/README.md @@ -15,13 +15,13 @@ daslang utils/daspkg/main.das -- install daspkg-test-pure daslang utils/daspkg/main.das -- install github.com/borisbat/daspkg-test-pure # install a specific version -daslang utils/daspkg/main.das -- install github.com/borisbat/daspkg-test-versions@v1.0 +daslang utils/daspkg/main.das -- install github.com/borisbat/daspkg-test-versions@1.0 # install all dependencies listed in .das_package daslang utils/daspkg/main.das -- install -# update all packages -daslang utils/daspkg/main.das -- update +# install globally (shared across projects) +daslang utils/daspkg/main.das -- install --global dasImgui ``` ## Commands @@ -31,7 +31,8 @@ daslang utils/daspkg/main.das -- update | `install [@version]` | Install a package from git, index, or local path | | `install` (no args) | Install all dependencies from `.das_package` | | `remove ` | Remove an installed package | -| `update [name]` | Update one or all packages | +| `update [name]` | Re-install at pinned version (re-clone, rebuild) | +| `upgrade [name]` | Upgrade to latest version | | `list` | List installed packages | | `search ` | Search the package index | | `build` | Build all C/C++ packages (cmake) | @@ -40,201 +41,39 @@ daslang utils/daspkg/main.das -- update | `introduce [url]` | Submit a package to the index via PR | | `withdraw ` | Remove a package from the index via PR | -Options: `--root ` (project root, default `.`), `--force` (force reinstall). +All package commands accept `--global` / `-g` to operate on global modules. -## .das_package manifest +### Options -Every package is described by a `.das_package` file — an executable daslang script that registers metadata, version resolution, dependencies, and build info. This replaces static JSON manifests with programmable logic. - -```das -options gen2 - -require daslib/daspkg - -// Required: package identity -[export] -def package() { - package_name("mymodule") - package_author("username") - package_description("What this module does") - package_source("github.com/user/mymodule") -} - -// Optional: how to resolve a version request into a git ref -[export] -def resolve(sdk_version, version : string) { - if (version == "" || version == "latest") { - download_tag("v2.0") // checkout a tag - } elif (version == "1.x") { - download_tag("v1.5") - } else { - download_tag("v{version}") // e.g. "1.2.3" -> tag "v1.2.3" - } - // alternatives: - // download_branch("main") - // download_redirect("github.com/other/repo", "v3.0") -} - -// Optional: dependencies on other packages -[export] -def dependencies(version : string) { - require_package("github.com/user/dep-a", ">=1.0") - require_package("github.com/user/dep-b") -} - -// Optional: build step (default: no build) -[export] -def build() { - cmake_build() // cmake configure + build - // alternatives: - // custom_build("make all") - // no_build() -} -``` - -### Version resolution - -The `resolve()` function decides what git ref to check out. It receives the daslang SDK version and the user-requested version, and calls one of: - -| Function | Effect | -|----------|--------| -| `download_tag("v1.0")` | Fetch and checkout git tag `v1.0` | -| `download_branch("main")` | Fetch and checkout branch `main` | -| `download_redirect("github.com/org/new-repo", "v2.0")` | Re-clone from a different repository and checkout tag `v2.0` | - -If `.das_package` has no `resolve()` function, the version string (from `install foo@v1.0`) is used as a tag directly. If no version is specified, the default branch is used. - -### Install flow - -1. Shallow-clone the package from the default branch -2. If `.das_package` exists and has `resolve()` — call it, checkout the resolved tag/branch (or re-clone on redirect) -3. If resolve returned nothing and a version was requested — checkout version as tag -4. Move to `modules//` -5. Record in `daspkg.lock` -6. Install transitive dependencies (from `.das_package` `dependencies()`) -7. Auto-build if `.das_package` has `build()` (cmake or custom) - -## Project layout - -### Source modules (`utils/daspkg/`) - -| File | Description | +| Flag | Description | |------|-------------| -| `main.das` | CLI entry point — parses args, dispatches to commands | -| `commands.das` | Command implementations: install, remove, update, build, check, doctor | -| `index.das` | Package index: fetch, search, introduce, withdraw | -| `lockfile.das` | `daspkg.lock` read/write (JSON serialization of installed packages) | -| `package_runner.das` | In-process `.das_package` compiler — compiles, simulates, and extracts metadata | -| `utils.das` | Shared utilities: `run_cmd`, `package_name_from_source`, `copy_dir_rec` | -| `DESIGN.md` | Original design document | - -### API module (`daslib/daspkg.das`) - -The module that `.das_package` scripts `require`. Provides the registration functions: - -- **Metadata:** `package_name()`, `package_author()`, `package_description()`, `package_source()` -- **Resolution:** `download_tag()`, `download_branch()`, `download_redirect()` -- **Dependencies:** `require_package(source, version_constraint)` -- **Build:** `cmake_build()`, `custom_build(command)`, `no_build()` - -State is stored in module globals (`_pkg_name`, `_download_ref`, etc.) and read back by `package_runner.das` via `get_context_global_variable()` after execution. - -### Example projects (`examples/daspkg/`) +| `--root ` | Project root (default: current directory) | +| `--force` | Force reinstall (overrides duplicate/version checks) | +| `--global`, `-g` | Operate on global modules in `{das_root}/modules/` | +| `--color` / `--no-color` | Enable/disable ANSI colored output | +| `--verbose`, `-v` | Print debug details (git commands, resolve steps) | +| `--json` | Machine-readable JSON output (`search`, `list`, `check`) | -| Directory | Description | -|-----------|-------------| -| `examples/daspkg/daspkg-example/` | Pure daslang install from git (uses `daspkg-test-pure` + `daspkg-test-deps`) | -| `examples/daspkg/daspkg-build-example/` | C/C++ native module install + cmake build workflow | -| `examples/daspkg/daspkg-version-1/` | Install specific version (v1.0) — only `get_version()` available | -| `examples/daspkg/daspkg-version-2/` | Install specific version (v2.0) — adds `new_in_v2()` API | -| `examples/daspkg/packages/` | Example package sources (below) | +## Global modules -### Example packages (`examples/daspkg/packages/`) - -| Package | Type | `.das_package` features | -|---------|------|------------------------| -| `daspkg-example-c` | C module (daScriptC.h) | `package()` + `cmake_build()` | -| `daspkg-example-cpp` | C++ module (daScript.h) | `package()` + `cmake_build()` | -| `daspkg-example-mixed` | Mixed das + C dependency | `package()` + `dependencies()` | - -### Test repositories (GitHub) - -| Repository | Purpose | `.das_package` | -|------------|---------|---------------| -| [`borisbat/daspkg-test-pure`](https://github.com/borisbat/daspkg-test-pure) | Pure daslang module | `package()` only | -| [`borisbat/daspkg-test-versions`](https://github.com/borisbat/daspkg-test-versions) | Module with version tags (v1.0, v2.0) | `package()` + `resolve()` | -| [`borisbat/daspkg-test-deps`](https://github.com/borisbat/daspkg-test-deps) | Module with transitive dependency | `package()` + `dependencies()` | -| [`borisbat/daspkg-index`](https://github.com/borisbat/daspkg-index) | Central package index (`packages.json`) | N/A | - -### Test fixtures (`utils/daspkg/fixtures/`) - -| Fixture | Tests | -|---------|-------| -| `test.das_package` | All four functions: package, resolve (tag), dependencies, build (cmake) | -| `test_branch.das_package` | Branch-based resolution | -| `test_redirect.das_package` | Redirect to a different repository | -| `test_sdk_aware.das_package` | Version-dependent resolution (latest/1.x/explicit) | - -### Tests - -| File | Count | Type | -|------|-------|------| -| `test_daspkg.das` | 80 | Unit tests (local operations, parsing, package_runner) | -| `test_daspkg_git.das` | 34 | Integration tests (git clone, version resolve, index fetch, full workflows) | - -Run tests: -```bash -# unit tests (local operations, parsing, package_runner) — fast, no network -daslang dastest/dastest.das -- --test utils/daspkg/test_daspkg.das - -# integration tests (git clone, version resolve, index fetch) — requires network -daslang dastest/dastest.das -- --test utils/daspkg/test_daspkg_git.das -``` - -### Manual example testing +Large packages (e.g. dasImgui) can be installed **globally** — once under `{das_root}/modules/` — shared across all projects using that SDK. Avoids redundant clones and builds. ```bash -# version-pinned install — v1.0 (only get_version()) -cd examples/daspkg/daspkg-version-1 -daslang ../../../utils/daspkg/main.das -- --root . install -daslang main.das -# expected: version = 1.0 - -# version-pinned install — v2.0 (get_version() + new_in_v2()) -cd examples/daspkg/daspkg-version-2 -daslang ../../../utils/daspkg/main.das -- --root . install -daslang main.das -# expected: version = 2.0 -# new in v2 = this function only exists in v2 - -# pure daslang install from git -cd examples/daspkg/daspkg-example -daslang ../../../utils/daspkg/main.das -- --root . install -dalang main.das - -# C/C++ build workflow -cd examples/daspkg/daspkg-build-example -daslang ../../../utils/daspkg/main.das -- --root . install -daslang ../../../utils/daspkg/main.das -- --root . build -daslang main.das +daspkg install --global dasImgui # install to das_root/modules/ +daspkg list --global # list global packages +daspkg update --global dasImgui # re-install at pinned version +daspkg remove --global dasImgui # remove globally ``` -## Use-case workflows - -### 1. Pure daslang package (no build step) +- **Local install auto-uses global:** `daspkg install foo` checks the global lock file first. If compatible, records a reference instead of cloning. +- **Version mismatch:** errors with a suggestion. Use `--force` to install locally, or `--global` to update the global copy. +- **Shadow detection:** if a module exists both locally and globally, the local version wins (warning printed). Removing the local copy falls back to global. +- **CMake:** global packages with native builds get a `.daspkg_standalone` marker so the main CMake skips them during auto-discovery. -A module that's just `.das` files — the most common case. +## `.das_package` manifest -**Package structure:** -``` -mymodule/ - .das_package - .das_module - daslib/ - mymodule.das -``` +Executable daslang script declaring metadata, version resolution, dependencies, and build info. -**`.das_package`:** ```das options gen2 require daslib/daspkg @@ -242,245 +81,167 @@ require daslib/daspkg [export] def package() { package_name("mymodule") - package_description("My pure daslang module") -} -``` - -**User installs:** -```bash -daspkg install github.com/user/mymodule -``` - -### 2. C/C++ package with cmake build - -A module that wraps native code. daspkg auto-builds it after install. - -**`.das_package`:** -```das -options gen2 -require daslib/daspkg - -[export] -def package() { - package_name("fastmath") - package_description("Fast math via native C code") -} - -[export] -def build() { - cmake_build() -} -``` - -**What happens on install:** -1. Clone repo -2. `cmake -B _build -S . -DDASLANG_DIR=` -3. `cmake --build _build --config Release` -4. The built shared library is available in `modules/fastmath/_build/` - -### 3. Package with dependencies - -A module that depends on other packages. - -**`.das_package`:** -```das -options gen2 -require daslib/daspkg - -[export] -def package() { - package_name("game-utils") - package_description("Game utility collection") -} - -[export] -def dependencies(version : string) { - require_package("github.com/user/math-lib") - require_package("github.com/user/ecs-helpers", ">=2.0") -} -``` - -**What happens on install:** -1. Clone `game-utils` -2. Read `.das_package`, call `dependencies()` -3. For each dependency not already installed, recursively `install_git` -4. All packages end up in `modules/` - -### 4. Version-pinned install - -Install a specific version of a package. - -```bash -daspkg install github.com/user/mymodule@v1.0 -``` - -**If the package has `resolve()`**, it receives `version="v1.0"` and can transform it (e.g., map `"1.0"` to tag `"v1.0"`). - -**If no `resolve()`**, the version string is used as a git tag directly. - -### 5. SDK-aware version resolution - -A package that ships different versions for different SDK releases. - -**`.das_package`:** -```das -options gen2 -require daslib/daspkg -require strings - -[export] -def package() { - package_name("compat-lib") - package_description("SDK-aware compatibility library") + package_author("username") + package_description("What this module does") + package_source("github.com/user/mymodule") + package_license("MIT") + package_tag("networking") + package_min_sdk("0.4") } [export] def resolve(sdk_version, version : string) { if (version == "" || version == "latest") { download_tag("v2.0") - } elif (version == "1.x") { - download_tag("v1.5") } else { download_tag("v{version}") } + // alternatives: download_branch("main"), download_redirect("github.com/other/repo", "v3.0") } -``` - -### 6. Repository redirect (package moved) - -A package that has moved to a new repository. The old repo's `.das_package` redirects. - -**`.das_package` (in the old repo):** -```das -options gen2 -require daslib/daspkg [export] -def package() { - package_name("old-name") - package_description("This package has moved") +def dependencies(version : string) { + require_package("github.com/user/dep-a", ">=1.0") + require_package("github.com/user/dep-b") } [export] -def resolve(sdk_version, version : string) { - download_redirect("github.com/neworg/new-repo", "v3.0") +def build() { + cmake_build() // or: custom_build("make all"), or: no_build() } ``` -**What happens:** daspkg clones the old repo, reads `.das_package`, sees the redirect, discards the clone, re-clones from the new repo, and checks out `v3.0`. - -### 7. Branch tracking (development/nightly) +All functions except `package()` are optional. A repo without `.das_package` gets a "dumb clone" — no version resolution, no deps, no build. -A package that tracks a branch instead of tagged releases. +## Install flow -**`.das_package`:** -```das -options gen2 -require daslib/daspkg +1. Shallow-clone from default branch +2. Run `.das_package` `resolve()` → checkout resolved tag/branch (or re-clone on redirect) +3. Move to `modules//` +4. Record in `daspkg.lock` +5. Install transitive dependencies +6. Auto-build if `.das_package` has `build()` -[export] -def package() { - package_name("bleeding-edge") - package_description("Always latest from main branch") -} +## Project layout -[export] -def resolve(sdk_version, version : string) { - download_branch("main") -} ``` +my_project/ + main.das + .das_package # project manifest (lists dependencies) + daspkg.lock # installed packages, versions, sources + modules/ + / + .das_module # provided by package author + .das_package # package manifest + _build/ # cmake build directory (if C++ package) + *.shared_module # built C++ output (if applicable) + .daspkg_cache/ # index cache (gitignored) + .daspkg_tmp/ # temp dir during install (gitignored) -### 8. Install from package index by name - -Packages registered in the [daspkg-index](https://github.com/borisbat/daspkg-index) can be installed by bare name: - -```bash -daspkg search math # find packages -daspkg install mymodule # resolves name -> URL via index +{das_root}/ # daScript SDK root (for global modules) + modules/ + .daspkg_global.lock # global lock file + / + .daspkg_standalone # marker: built by daspkg, skip in CMake ``` -### 9. Local package (development) - -Install a package from a local directory (copies files, no git): +## Lock file (`daspkg.lock`) -```bash -daspkg install ./path/to/mymodule +```json +{ + "sdk_version": "", + "packages": [ + { + "name": "mymodule", + "source": "github.com/user/mymodule", + "version": "1.0", + "tag": "v1.0", + "branch": "", + "root": true, + "local": false, + "global": false + } + ] +} ``` -Useful during development — edit the source, then `daspkg update mymodule` to re-copy. - -### 10. Project with .das_package at root +- **root** — `true` if user-installed, `false` if transitive dependency +- **tag/branch** — resolved git ref +- **local** — installed from local path +- **global** — resolved from global install (no local copy) -A project can list its own dependencies in a root `.das_package`: - -**`.das_package` (project root):** -```das -options gen2 -require daslib/daspkg +The global lock file (`{das_root}/modules/.daspkg_global.lock`) uses the same format. -[export] -def package() { - package_name("my-game") - package_description("My game project") -} +## Architecture -[export] -def dependencies(version : string) { - require_package("github.com/user/ecs-lib") - require_package("github.com/user/render-lib") -} -``` +| File | Description | +|------|-------------| +| `main.das` | CLI entry point — parses args, dispatches to commands | +| `commands.das` | Command implementations: install, remove, update, upgrade, build, check, doctor | +| `index.das` | Package index: fetch, search, introduce, withdraw | +| `lockfile.das` | `LockFile` / `PackageEntry` structs, JSON serialization | +| `package_runner.das` | In-process `.das_package` compiler — compiles, simulates, extracts metadata | +| `utils.das` | Shared utilities: `run_cmd`, `force_rmdir`, path helpers | +| `daslib/daspkg.das` | API module that `.das_package` scripts `require` | -```bash -cd my-game/ -daspkg install # installs all dependencies -daspkg update # updates all -daspkg check # verifies everything is present -``` +The **package runner** compiles `.das_package` scripts in-process using `compile_file` + `simulate` + `invoke_in_context`. It calls exported functions and reads state from `daslib/daspkg` module globals via `get_context_global_variable`. -### 11. Publishing a package +## Tests -Register your package in the central index: +| File | Count | Type | +|------|-------|------| +| `test_daspkg.das` | 151 | Unit tests (local operations, parsing, lock file, package_runner) | +| `test_daspkg_git.das` | 70 | Integration tests (git clone, version resolve, index, global modules) | ```bash -# from your package directory (reads .das_package for name/description) -daspkg introduce - -# or by URL -daspkg introduce github.com/user/mymodule -``` - -This clones the index repo, adds your package to `packages.json`, and creates a PR on GitHub (requires `gh` CLI). +# unit tests — fast, no network +daslang dastest/dastest.das -- --test utils/daspkg/test_daspkg.das -To remove: -```bash -daspkg withdraw mymodule +# integration tests — requires network +daslang dastest/dastest.das -- --test utils/daspkg/test_daspkg_git.das ``` -## Lock file (`daspkg.lock`) - -Tracks installed packages with their source, version, resolved tag/branch, and whether they're root or transitive dependencies: +### Test repositories (GitHub) -```json -{ - "sdk_version": "", - "packages": { - "mymodule": { - "source": "github.com/user/mymodule", - "version": "v1.0", - "tag": "v1.0", - "branch": "", - "root": true, - "local": false - } - } -} -``` +| Repository | Purpose | +|------------|---------| +| [`borisbat/daspkg-test-pure`](https://github.com/borisbat/daspkg-test-pure) | Pure daslang module | +| [`borisbat/daspkg-test-versions`](https://github.com/borisbat/daspkg-test-versions) | Module with version tags (v1.0, v2.0) | +| [`borisbat/daspkg-test-deps`](https://github.com/borisbat/daspkg-test-deps) | Module with transitive dependency | +| [`borisbat/daspkg-index`](https://github.com/borisbat/daspkg-index) | Central package index | + +## Design rationale + +### Why git-based (like Go modules)? +- Packages are git repositories — no centralized registry server to maintain +- Version = git tag. `resolve()` maps version requests to tags. +- Index is a curated JSON file in a git repo. Authors add via PR (`daspkg introduce`). + +### Why executable manifests? +- Authors can put version-conditional logic in `resolve()` and `dependencies()` +- Same pattern as `.das_module` — familiar to daslang authors +- daspkg provides registration functions via `daslib/daspkg`; `.das_package` just calls them + +### Why per-project by default? +- Game projects need reproducible builds — `modules/` is self-contained +- Global install (`--global`) for large shared modules (dasImgui, etc.) +- Runtime already scans both `das_root/modules/` and project `modules/` + +### Version model +- **Package version** — semver, resolved via `.das_package` `resolve()` +- **SDK version** — `resolve()` receives it, can return different tags per SDK +- **Dependency constraints** — operators `>=`, `>`, `<=`, `<`, `=`, comma AND: `">=1.0,<2.0"` +- **Diamond deps** — first installed wins (single `require` namespace). Manual upgrade with `--force`. + +### Transport +- Only external dependency: `git` CLI. No HTTP library needed. +- Shallow clones (`--depth 1`) for speed. +- Local installs: filesystem copy, no git. ## Requirements - **git** — required for all remote operations - **cmake** — required for building C/C++ packages -- **gh** (GitHub CLI) — optional, only needed for `introduce`/`withdraw` +- **gh** (GitHub CLI) — optional, only for `introduce`/`withdraw` Run `daspkg doctor` to check your environment. diff --git a/utils/daspkg/commands.das b/utils/daspkg/commands.das index 5e12d517ad..07d394efe5 100644 --- a/utils/daspkg/commands.das +++ b/utils/daspkg/commands.das @@ -26,6 +26,7 @@ struct ParsedArgs { command_arg : string force : bool json : bool + global_flag : bool } def parse_args(args : array) : ParsedArgs { @@ -49,6 +50,8 @@ def parse_args(args : array) : ParsedArgs { use_tty_colors = false } elif (arg == "--json") { result.json = true + } elif (arg == "--global" || arg == "-g") { + result.global_flag = true } elif (arg == "--verbose" || arg == "-v") { log_verbose_flag = true } elif (arg == "--root" && i + 1 < length(args)) { @@ -64,13 +67,14 @@ def parse_args(args : array) : ParsedArgs { return result } -def cmd_list(root : string; json : bool = false) { +def cmd_list(root : string; json : bool = false; is_global : bool = false) { var lf : LockFile - if (!read_lock_file(root, lf)) { + let have_lock = is_global ? read_global_lock_file(lf) : read_lock_file(root, lf) + if (!have_lock) { if (json) { print("[]\n") } else { - print("No packages installed (no lock file found)\n") + print("No packages installed{is_global ? " globally" : ""} (no lock file found)\n") } return } @@ -78,7 +82,7 @@ def cmd_list(root : string; json : bool = false) { if (json) { print("[]\n") } else { - print("No packages installed\n") + print("No packages installed{is_global ? " globally" : ""}\n") } return } @@ -87,12 +91,15 @@ def cmd_list(root : string; json : bool = false) { print("\n") return } - print("Installed packages:\n") + print("{is_global ? "Global packages" : "Installed packages"}:\n") for (entry in lf.packages) { var flags : string if (entry.local) { flags = " (local)" } + if (entry.global && !is_global) { + flags = "{flags} (global)" + } if (!entry.root) { flags = "{flags} (dependency)" } @@ -106,8 +113,8 @@ def cmd_list(root : string; json : bool = false) { } } -def install_local(root, pkg_name, source : string; var lf : LockFile) { - let modules_dir = "{root}/modules" +def install_local(root, pkg_name, source : string; var lf : LockFile; is_global : bool = false) { + let modules_dir = is_global ? "{get_das_root()}/modules" : "{root}/modules" let target = "{modules_dir}/{pkg_name}" let full_source = get_full_file_name(source) log_debug("install_local: '{pkg_name}' from '{full_source}' -> '{target}'") @@ -121,7 +128,7 @@ def install_local(root, pkg_name, source : string; var lf : LockFile) { if (fexist(target)) { log("Removing existing {target}...\n") - rmdir_rec(target) + force_rmdir(target) } log("Copying {full_source} -> {target}\n") @@ -130,19 +137,24 @@ def install_local(root, pkg_name, source : string; var lf : LockFile) { return } - set_package(lf, PackageEntry(name = pkg_name, source = full_source, local = true, root = true)) - write_lock_file(root, lf) - log("Installed '{pkg_name}' (local)\n") - try_build_package(target, pkg_name) + set_package(lf, PackageEntry(name = pkg_name, source = full_source, local = true, root = true, global = is_global)) + if (is_global) { + write_global_lock_file(lf) + } else { + write_lock_file(root, lf) + } + log("Installed '{pkg_name}' (local{is_global ? ", global" : ""})\n") + try_build_package(target, pkg_name, is_global) } -def install_dependencies(root, pkg_name : string; var lf : LockFile; var visiting : table) { +def install_dependencies(root, pkg_name : string; var lf : LockFile; var visiting : table; is_global : bool = false) { if (key_exists(visiting, pkg_name)) { log("Warning: dependency cycle detected at '{pkg_name}' — skipping\n") return } visiting |> insert(pkg_name, true) - let pkg_dir = "{root}/modules/{pkg_name}" + let modules_dir = is_global ? "{get_das_root()}/modules" : "{root}/modules" + let pkg_dir = "{modules_dir}/{pkg_name}" let das_package = "{pkg_dir}/.das_package" if (!fexist(das_package)) { return @@ -156,8 +168,13 @@ def install_dependencies(root, pkg_name : string; var lf : LockFile; var visitin log_debug("dependency '{dep_name}' already installed — skipping") continue } + // when global, also skip if directory already exists (built-in SDK modules) + if (is_global && fexist("{modules_dir}/{dep_name}")) { + log_debug("dependency '{dep_name}' already present in {modules_dir} — skipping") + continue + } log(" Installing dependency: {dep_name}...\n") - install_git(root, dep_name, d.source, "", lf, visiting, [is_root = false]) + install_git(root, dep_name, d.source, "", lf, visiting, [is_root = false, is_global = is_global]) } } else { log_debug("{pkg_name}: no dependencies declared") @@ -205,19 +222,24 @@ def private git_clone(url, dir : string) : bool { return true } -def install_git(root, pkg_name, source, version : string; var lf : LockFile; is_root : bool = true) { +def install_git(root, pkg_name, source, version : string; var lf : LockFile; is_root : bool = true; is_global : bool = false) { var visiting : table - install_git(root, pkg_name, source, version, lf, visiting, [is_root = is_root]) + install_git(root, pkg_name, source, version, lf, visiting, [is_root = is_root, is_global = is_global]) } -def install_git(root, pkg_name, source, version : string; var lf : LockFile; var visiting : table; is_root : bool = true) { - let modules_dir = "{root}/modules" +def install_git(root, pkg_name, source, version : string; var lf : LockFile; var visiting : table; is_root : bool = true; is_global : bool = false) { + let modules_dir = is_global ? "{get_das_root()}/modules" : "{root}/modules" let tmp_dir = "{modules_dir}/.daspkg_tmp/{pkg_name}" let target_dir = "{modules_dir}/{pkg_name}" mkdir(modules_dir) mkdir("{modules_dir}/.daspkg_tmp") + // clean stale tmp dir from a previous failed install + if (fexist(tmp_dir)) { + force_rmdir(tmp_dir) + } + // step 1: shallow clone default branch let git_url = "https://{source}.git" log("Cloning {git_url}...\n") @@ -239,7 +261,7 @@ def install_git(root, pkg_name, source, version : string; var lf : LockFile; var if (!empty(res.source)) { // redirect — re-clone from a different repo log("Redirecting to {res.source}...\n") - rmdir_rec(tmp_dir) + force_rmdir(tmp_dir) actual_source = res.source let redirect_url = "https://{res.source}.git" if (!git_clone(redirect_url, tmp_dir)) { @@ -249,14 +271,14 @@ def install_git(root, pkg_name, source, version : string; var lf : LockFile; var if (!empty(res.tag)) { log("Fetching tag {res.tag}...\n") if (!git_checkout_tag(tmp_dir, res.tag)) { - rmdir_rec(tmp_dir) + force_rmdir(tmp_dir) return } actual_tag = res.tag } elif (!empty(res.branch)) { log("Switching to branch {res.branch}...\n") if (!git_checkout_branch(tmp_dir, res.branch)) { - rmdir_rec(tmp_dir) + force_rmdir(tmp_dir) return } actual_branch = res.branch @@ -265,7 +287,7 @@ def install_git(root, pkg_name, source, version : string; var lf : LockFile; var // tag checkout log("Fetching tag {res.tag}...\n") if (!git_checkout_tag(tmp_dir, res.tag)) { - rmdir_rec(tmp_dir) + force_rmdir(tmp_dir) return } actual_tag = res.tag @@ -273,7 +295,7 @@ def install_git(root, pkg_name, source, version : string; var lf : LockFile; var // branch checkout log("Switching to branch {res.branch}...\n") if (!git_checkout_branch(tmp_dir, res.branch)) { - rmdir_rec(tmp_dir) + force_rmdir(tmp_dir) return } actual_branch = res.branch @@ -284,7 +306,7 @@ def install_git(root, pkg_name, source, version : string; var lf : LockFile; var // try with v prefix log("Fetching tag v{version}...\n") if (!git_checkout_tag(tmp_dir, "v{version}")) { - rmdir_rec(tmp_dir) + force_rmdir(tmp_dir) return } actual_tag = "v{version}" @@ -301,7 +323,7 @@ def install_git(root, pkg_name, source, version : string; var lf : LockFile; var // try with v prefix log("Fetching tag v{version}...\n") if (!git_checkout_tag(tmp_dir, "v{version}")) { - rmdir_rec(tmp_dir) + force_rmdir(tmp_dir) return } actual_tag = "v{version}" @@ -314,7 +336,7 @@ def install_git(root, pkg_name, source, version : string; var lf : LockFile; var log_debug("moving {tmp_dir} -> {target_dir}") if (fexist(target_dir)) { log("Removing existing {target_dir}...\n") - rmdir_rec(target_dir) + force_rmdir(target_dir) } if (!rename(tmp_dir, target_dir)) { log("Error: failed to move {tmp_dir} to {target_dir}\n") @@ -323,20 +345,24 @@ def install_git(root, pkg_name, source, version : string; var lf : LockFile; var // step 4: record in lock file set_package(lf, PackageEntry(name = pkg_name, source = actual_source, version = version, - tag = actual_tag, branch = actual_branch, root = is_root)) - write_lock_file(root, lf) - log("Installed '{pkg_name}'\n") + tag = actual_tag, branch = actual_branch, root = is_root, global = is_global)) + if (is_global) { + write_global_lock_file(lf) + } else { + write_lock_file(root, lf) + } + log("Installed '{pkg_name}'{is_global ? " (global)" : ""}\n") // step 5: install dependencies log_debug("checking dependencies for '{pkg_name}'") - install_dependencies(root, pkg_name, lf, visiting) + install_dependencies(root, pkg_name, lf, visiting, is_global) // step 6: build log_debug("checking build for '{pkg_name}'") - try_build_package(target_dir, pkg_name) + try_build_package(target_dir, pkg_name, is_global) } -def cmd_install(root, spec : string; force : bool) { +def cmd_install(root, spec : string; force : bool; is_global : bool = false; version_constraint : string = "") { // parse spec: name[@version] var source = spec var version = "" @@ -357,7 +383,41 @@ def cmd_install(root, spec : string; force : bool) { return } - // check if already installed + log_debug("cmd_install: pkg='{pkg_name}' source='{source}' version='{version}' global={is_global}") + + if (is_global) { + // global install — read global lock file, install to das_root/modules/ + var glf : LockFile + read_global_lock_file(glf) + if (!force && has_package(glf, pkg_name)) { + log("'{pkg_name}' is already installed globally (use --force to reinstall)\n") + return + } + // also skip if directory already exists (built-in SDK modules) + let global_dir = "{get_das_root()}/modules/{pkg_name}" + if (!force && fexist(global_dir) && !has_package(glf, pkg_name)) { + log("'{pkg_name}' already present in {get_das_root()}/modules/ — skipping\n") + return + } + if (is_local_path(source)) { + install_local(root, pkg_name, source, glf, [is_global = true]) + } elif (is_index_name(source)) { + let url = resolve_from_index(root, source) + if (empty(url)) { + log("Error: package '{source}' not found in the index\n") + return + } + log("Resolved '{source}' -> {url}\n") + var visiting : table + install_git(root, pkg_name, url, version, glf, visiting, [is_root = true, is_global = true]) + } else { + var visiting : table + install_git(root, pkg_name, source, version, glf, visiting, [is_root = true, is_global = true]) + } + return + } + + // local install — check global first var lf : LockFile read_lock_file(root, lf) if (!force && has_package(lf, pkg_name)) { @@ -365,7 +425,30 @@ def cmd_install(root, spec : string; force : bool) { return } - log_debug("cmd_install: pkg='{pkg_name}' source='{source}' version='{version}'") + // check if a compatible global version exists + var glf : LockFile + if (read_global_lock_file(glf) && has_package(glf, pkg_name)) { + let ge = get_package(glf, pkg_name) + // use version_constraint (from .das_package deps) if available, otherwise version (from @spec) + let constraint = !empty(version_constraint) ? version_constraint : version + if (empty(constraint) || satisfies_constraint(ge.tag, constraint)) { + // compatible global version — use it + set_package(lf, PackageEntry(name = pkg_name, source = ge.source, version = ge.version, + tag = ge.tag, branch = ge.branch, root = true, global = true)) + write_lock_file(root, lf) + log("Using global '{pkg_name}' @ {ge.tag}\n") + return + } else { + // version mismatch + log("Error: global '{pkg_name}' is {ge.tag}, but project requires {constraint}\n") + log(" Use --force to install locally, or: daspkg install --global {source}@{constraint}\n") + if (!force) { + return + } + // --force: fall through to local install + } + } + if (is_local_path(source)) { install_local(root, pkg_name, source, lf) } elif (is_index_name(source)) { @@ -385,7 +468,7 @@ def cmd_install(root, spec : string; force : bool) { } } -def cmd_install_all(root : string; force : bool) { +def cmd_install_all(root : string; force : bool; is_global : bool = false) { let das_package = "{root}/.das_package" if (!fexist(das_package)) { print("No .das_package found\n") @@ -397,11 +480,43 @@ def cmd_install_all(root : string; force : bool) { return } for (d in pkg_deps) { - cmd_install(root, d.source, force) + // append version constraint as @version if present (e.g. "=1.0" -> "@1.0") + var spec = d.source + if (!empty(d.version_constraint)) { + // strip leading operator for git version (=1.0 -> 1.0, >=1.0 -> 1.0) + var ver = d.version_constraint + if (starts_with(ver, ">=") || starts_with(ver, "<=")) { + ver = slice(ver, 2) + } elif (starts_with(ver, ">") || starts_with(ver, "<") || starts_with(ver, "=")) { + ver = slice(ver, 1) + } + spec = "{spec}@{ver}" + } + cmd_install(root, spec, force, is_global, d.version_constraint) } } -def cmd_remove(root, name : string) { +def cmd_remove(root, name : string; is_global : bool = false) { + if (is_global) { + var glf : LockFile + if (!read_global_lock_file(glf)) { + log("Error: no global lock file found\n") + return + } + if (!has_package(glf, name)) { + log("Error: package '{name}' is not installed globally\n") + return + } + let pkg_dir = "{get_das_root()}/modules/{name}" + if (fexist(pkg_dir)) { + log("Removing {pkg_dir}...\n") + force_rmdir(pkg_dir) + } + remove_package(glf, name) + write_global_lock_file(glf) + log("Removed '{name}' (global)\n") + return + } var lf : LockFile if (!read_lock_file(root, lf)) { log("Error: no lock file found\n") @@ -411,11 +526,19 @@ def cmd_remove(root, name : string) { log("Error: package '{name}' is not installed\n") return } + let entry = get_package(lf, name) + if (entry.global) { + // only remove the project reference, not the global directory + remove_package(lf, name) + write_lock_file(root, lf) + log("Removed global reference to '{name}'. Global package still installed.\n") + return + } let pkg_dir = "{root}/modules/{name}" if (fexist(pkg_dir)) { log("Removing {pkg_dir}...\n") - rmdir_rec(pkg_dir) + force_rmdir(pkg_dir) } remove_package(lf, name) @@ -423,17 +546,28 @@ def cmd_remove(root, name : string) { log("Removed '{name}'\n") } -def cmd_update(root, name : string) { +def cmd_update(root, name : string; is_global : bool = false) { var lf : LockFile - if (!read_lock_file(root, lf)) { - log("Error: no lock file found\n") - return + if (is_global) { + if (!read_global_lock_file(lf)) { + log("Error: no global lock file found\n") + return + } + } else { + if (!read_lock_file(root, lf)) { + log("Error: no lock file found\n") + return + } } if (!has_package(lf, name)) { - log("Error: package '{name}' is not installed\n") + log("Error: package '{name}' is not installed{is_global ? " globally" : ""}\n") return } let entry = get_package(lf, name) + if (!is_global && entry.global) { + log("'{name}' is a global package — use 'daspkg update --global {name}'\n") + return + } if (entry.local) { // re-copy from original source path let source = entry.source @@ -441,32 +575,34 @@ def cmd_update(root, name : string) { log("Error: local source '{source}' not found\n") return } - let target = "{root}/modules/{name}" + let modules_dir = is_global ? "{get_das_root()}/modules" : "{root}/modules" + let target = "{modules_dir}/{name}" log("Updating '{name}' from {source}...\n") if (fexist(target)) { - rmdir_rec(target) + force_rmdir(target) } if (!copy_dir_rec(source, target)) { log("Error: copy failed\n") return } log("Updated '{name}'\n") - try_build_package(target, name) + try_build_package(target, name, is_global) return } // re-install from source with same version — re-resolves via .das_package let source = entry.source let version = entry.version let is_root = entry.root - let pkg_dir = "{root}/modules/{name}" - let backup_dir = "{root}/modules/.daspkg_backup/{name}" - log("Updating '{name}'...\n") + let modules_dir = is_global ? "{get_das_root()}/modules" : "{root}/modules" + let pkg_dir = "{modules_dir}/{name}" + let backup_dir = "{modules_dir}/.daspkg_backup/{name}" + log("Updating '{name}'{is_global ? " (global)" : ""}...\n") // backup before deleting log_debug("backing up '{name}' to {backup_dir}") if (fexist(pkg_dir)) { - mkdir("{root}/modules/.daspkg_backup") + mkdir("{modules_dir}/.daspkg_backup") if (fexist(backup_dir)) { - rmdir_rec(backup_dir) + force_rmdir(backup_dir) } if (!rename(pkg_dir, backup_dir)) { log("Error: failed to backup '{name}'\n") @@ -475,7 +611,7 @@ def cmd_update(root, name : string) { } remove_package(lf, name) var visiting : table - install_git(root, name, source, version, lf, visiting, [is_root = is_root]) + install_git(root, name, source, version, lf, visiting, [is_root = is_root, is_global = is_global]) // check if install succeeded if (!has_package(lf, name)) { log_debug("install failed — restoring from {backup_dir}") @@ -484,12 +620,16 @@ def cmd_update(root, name : string) { rename(backup_dir, pkg_dir) } set_package(lf, entry) - write_lock_file(root, lf) + if (is_global) { + write_global_lock_file(lf) + } else { + write_lock_file(root, lf) + } return } // cleanup backup if (fexist(backup_dir)) { - rmdir_rec(backup_dir) + force_rmdir(backup_dir) } } @@ -498,7 +638,7 @@ def cmd_update(root, name : string) { def private resolve_latest(source : string; var res : ResolveResult) : bool { let tmp_dir = "{get_das_root()}/_daspkg_resolve_tmp" if (fexist(tmp_dir)) { - rmdir_rec(tmp_dir) + force_rmdir(tmp_dir) } let git_url = "https://{source}.git" if (!git_clone(git_url, tmp_dir)) { @@ -509,21 +649,32 @@ def private resolve_latest(source : string; var res : ResolveResult) : bool { if (fexist(das_package)) { ok = run_das_package_resolve(das_package, "", "", res) } - rmdir_rec(tmp_dir) + force_rmdir(tmp_dir) return ok } -def cmd_upgrade(root, name : string) { +def cmd_upgrade(root, name : string; is_global : bool = false) { var lf : LockFile - if (!read_lock_file(root, lf)) { - log("Error: no lock file found\n") - return + if (is_global) { + if (!read_global_lock_file(lf)) { + log("Error: no global lock file found\n") + return + } + } else { + if (!read_lock_file(root, lf)) { + log("Error: no lock file found\n") + return + } } if (!has_package(lf, name)) { - log("Error: package '{name}' is not installed\n") + log("Error: package '{name}' is not installed{is_global ? " globally" : ""}\n") return } let entry = get_package(lf, name) + if (!is_global && entry.global) { + log("'{name}' is a global package — use 'daspkg upgrade --global {name}'\n") + return + } if (entry.local) { log("'{name}' is a local package — use 'update' instead\n") return @@ -550,19 +701,21 @@ def cmd_upgrade(root, name : string) { // re-install at the new version (empty version = unpinned) let is_root = entry.root let source = entry.source - let pkg_dir = "{root}/modules/{name}" + let modules_dir = is_global ? "{get_das_root()}/modules" : "{root}/modules" + let pkg_dir = "{modules_dir}/{name}" if (fexist(pkg_dir)) { - rmdir_rec(pkg_dir) + force_rmdir(pkg_dir) } remove_package(lf, name) var visiting : table - install_git(root, name, source, "", lf, visiting, [is_root = is_root]) + install_git(root, name, source, "", lf, visiting, [is_root = is_root, is_global = is_global]) // check if deps need upgrading - upgrade_dependencies(root, name, lf) + upgrade_dependencies(root, name, lf, is_global) } -def upgrade_dependencies(root, pkg_name : string; var lf : LockFile) { - let pkg_dir = "{root}/modules/{pkg_name}" +def upgrade_dependencies(root, pkg_name : string; var lf : LockFile; is_global : bool = false) { + let modules_dir = is_global ? "{get_das_root()}/modules" : "{root}/modules" + let pkg_dir = "{modules_dir}/{pkg_name}" let das_package = "{pkg_dir}/.das_package" if (!fexist(das_package)) { return @@ -577,7 +730,7 @@ def upgrade_dependencies(root, pkg_name : string; var lf : LockFile) { // new dependency — install it log(" Installing new dependency: {dep_name}...\n") var visiting : table - install_git(root, dep_name, d.source, "", lf, visiting, [is_root = false]) + install_git(root, dep_name, d.source, "", lf, visiting, [is_root = false, is_global = is_global]) continue } // check if installed version satisfies constraint @@ -588,16 +741,23 @@ def upgrade_dependencies(root, pkg_name : string; var lf : LockFile) { let dep_version = dep_entry.tag if (!satisfies_constraint(dep_version, d.version_constraint)) { log(" Dependency '{dep_name}' @ {dep_version} does not satisfy {d.version_constraint} — upgrading...\n") - cmd_upgrade(root, dep_name) + cmd_upgrade(root, dep_name, is_global) } } } -def cmd_upgrade_all(root : string) { +def cmd_upgrade_all(root : string; is_global : bool = false) { var lf : LockFile - if (!read_lock_file(root, lf)) { - print("No packages installed (no lock file found)\n") - return + if (is_global) { + if (!read_global_lock_file(lf)) { + print("No packages installed globally\n") + return + } + } else { + if (!read_lock_file(root, lf)) { + print("No packages installed (no lock file found)\n") + return + } } // upgrade root packages only — deps get pulled along var names : array @@ -611,15 +771,22 @@ def cmd_upgrade_all(root : string) { return } for (name in names) { - cmd_upgrade(root, name) + cmd_upgrade(root, name, is_global) } } -def cmd_update_all(root : string) { +def cmd_update_all(root : string; is_global : bool = false) { var lf : LockFile - if (!read_lock_file(root, lf)) { - print("No packages installed (no lock file found)\n") - return + if (is_global) { + if (!read_global_lock_file(lf)) { + print("No packages installed globally\n") + return + } + } else { + if (!read_lock_file(root, lf)) { + print("No packages installed (no lock file found)\n") + return + } } // collect names first — cmd_update modifies lf.packages var names : array @@ -627,7 +794,7 @@ def cmd_update_all(root : string) { names |> push_clone(entry.name) } for (name in names) { - cmd_update(root, name) + cmd_update(root, name, is_global) } } @@ -652,8 +819,8 @@ def build_package(pkg_dir : string) : bool { return true } -def cmd_build(root : string) { - let modules_dir = "{root}/modules" +def cmd_build(root : string; is_global : bool = false) { + let modules_dir = is_global ? "{get_das_root()}/modules" : "{root}/modules" if (!fexist(modules_dir)) { print("No modules directory found\n") return @@ -709,6 +876,11 @@ def cmd_doctor() { if (!check_tool("cmake", "cmake --version")) { ok = false } + if (!check_tool("rm", "rm --version")) { + // rm is required for force_rmdir (removing git repos with read-only objects) + print(" (requires MSYS2, Git Bash, or Unix-like shell)\n") + ok = false + } if (!check_tool("gh", "gh --version")) { // gh is optional — only needed for introduce/withdraw print(" (optional — only needed for introduce/withdraw)\n") @@ -720,7 +892,8 @@ def cmd_doctor() { } } -def try_build_package(pkg_dir, name : string) { +def try_build_package(pkg_dir, name : string; is_global : bool = false) { + var did_build = false let das_package = "{pkg_dir}/.das_package" if (fexist(das_package)) { var info : PackageBuildInfo @@ -729,6 +902,8 @@ def try_build_package(pkg_dir, name : string) { log("Building '{name}'...\n") if (!build_package(pkg_dir)) { log(" Warning: build failed for '{name}'\n") + } else { + did_build = true } } elif (info.is_custom && !empty(info.command)) { log("Building '{name}' (custom)...\n") @@ -736,16 +911,28 @@ def try_build_package(pkg_dir, name : string) { let exit_code = run_cmd("cd \"{pkg_dir}\" && {info.command}", output) if (exit_code != 0) { log(" Warning: custom build failed for '{name}':\n{output}\n") + } else { + did_build = true } } } - return + } else { + // fallback: check for CMakeLists.txt directly + if (fexist("{pkg_dir}/CMakeLists.txt")) { + log("Building '{name}'...\n") + if (!build_package(pkg_dir)) { + log(" Warning: build failed for '{name}'\n") + } else { + did_build = true + } + } } - // fallback: check for CMakeLists.txt directly - if (fexist("{pkg_dir}/CMakeLists.txt")) { - log("Building '{name}'...\n") - if (!build_package(pkg_dir)) { - log(" Warning: build failed for '{name}'\n") + // mark standalone-build modules so main CMake skips them + if (is_global && did_build) { + fopen("{pkg_dir}/.daspkg_standalone", "wb") <| $(f) { + if (f != null) { + fwrite(f, "") + } } } } @@ -756,22 +943,35 @@ struct CheckResult { message : string } -def cmd_check(root : string; json : bool = false) { +def cmd_check(root : string; json : bool = false; is_global : bool = false) { var lf : LockFile - if (!read_lock_file(root, lf)) { - if (json) { - print("[]\n") - } else { - print("No packages installed\n") + if (is_global) { + if (!read_global_lock_file(lf)) { + if (json) { + print("[]\n") + } else { + print("No packages installed globally\n") + } + return + } + } else { + if (!read_lock_file(root, lf)) { + if (json) { + print("[]\n") + } else { + print("No packages installed\n") + } + return } - return } var results : array var ok = true for (entry in lf.packages) { - let pkg_dir = "{root}/modules/{entry.name}" + // global entries live at das_root/modules/, local at root/modules/ + let pkg_dir = (entry.global || is_global) ? "{get_das_root()}/modules/{entry.name}" : "{root}/modules/{entry.name}" if (!fexist(pkg_dir)) { - results |> emplace(CheckResult(name = entry.name, status = "missing", message = "directory not found")) + let location = entry.global ? " (global)" : "" + results |> emplace(CheckResult(name = entry.name, status = "missing", message = "directory not found{location}")) ok = false continue } diff --git a/utils/daspkg/index.das b/utils/daspkg/index.das index 5198310742..e786bd9397 100644 --- a/utils/daspkg/index.das +++ b/utils/daspkg/index.das @@ -740,7 +740,7 @@ def cmd_introduce(root, source : string) { let exit_code = run_cmd("git clone --depth 1 \"{git_url}\" \"{tmp_dir}\"", output) if (exit_code != 0) { print("Error: failed to clone {git_url}:\n{output}\n") - rmdir_rec(tmp_dir) + force_rmdir(tmp_dir) return } pkg_dir = tmp_dir @@ -751,7 +751,7 @@ def cmd_introduce(root, source : string) { var manifest : PackageManifest if (!read_manifest(pkg_dir, manifest)) { if (!empty(source)) { - rmdir_rec(pkg_dir) + force_rmdir(pkg_dir) } return } @@ -760,7 +760,7 @@ def cmd_introduce(root, source : string) { var idx : table if (!fetch_index(root, idx)) { if (!empty(source)) { - rmdir_rec(pkg_dir) + force_rmdir(pkg_dir) } print("Error: failed to fetch package index\n") return @@ -768,14 +768,14 @@ def cmd_introduce(root, source : string) { if (idx |> key_exists(manifest.name)) { print("Error: package '{manifest.name}' already exists in the index\n") if (!empty(source)) { - rmdir_rec(pkg_dir) + force_rmdir(pkg_dir) } return } // cleanup temp clone if (!empty(source)) { - rmdir_rec(pkg_dir) + force_rmdir(pkg_dir) } // step 4: modify index and create PR via gh @@ -904,7 +904,7 @@ def cmd_update_index(root : string) { print("Updating {name}...\n") let tmp_dir = "{tmp_base}/{name}" if (fexist(tmp_dir)) { - rmdir_rec(tmp_dir) + force_rmdir(tmp_dir) } let git_url = "https://{url}.git" var output : string @@ -973,12 +973,12 @@ def cmd_update_index(root : string) { } } - rmdir_rec(tmp_dir) + force_rmdir(tmp_dir) updated ++ } } - rmdir_rec(tmp_base) + force_rmdir(tmp_base) // write updated index let cache_dir = get_index_cache_dir(root) diff --git a/utils/daspkg/lockfile.das b/utils/daspkg/lockfile.das index 072f504753..a5e337644f 100644 --- a/utils/daspkg/lockfile.das +++ b/utils/daspkg/lockfile.das @@ -21,6 +21,7 @@ struct PackageEntry { branch : string // resolved git branch (if branch-based) root : bool = true local : bool = false + global : bool = false // installed in das_root/modules/ (shared across projects) } struct LockFile { @@ -29,11 +30,16 @@ struct LockFile { } let LOCK_FILE_NAME = "daspkg.lock" +let GLOBAL_LOCK_FILE_NAME = ".daspkg_global.lock" def lock_file_path(project_root : string) : string { return "{project_root}/{LOCK_FILE_NAME}" } +def global_lock_file_path() : string { + return "{get_das_root()}/modules/{GLOBAL_LOCK_FILE_NAME}" +} + // find index of package by name, -1 if not found def find_package(lf : LockFile; name : string) : int { for (i in range(length(lf.packages))) { @@ -73,8 +79,7 @@ def remove_package(var lf : LockFile; name : string) { } } -def read_lock_file(project_root : string; var lf : LockFile) : bool { - let path = lock_file_path(project_root) +def read_lock_file_at(path : string; var lf : LockFile) : bool { var ok = false fopen(path, "rb") <| $(f) { if (f == null) { @@ -93,8 +98,7 @@ def read_lock_file(project_root : string; var lf : LockFile) : bool { return ok } -def write_lock_file(project_root : string; lf : LockFile) : bool { - let path = lock_file_path(project_root) +def write_lock_file_at(path : string; lf : LockFile) : bool { let text = sprint_json(lf, true) var ok = false fopen(path, "wb") <| $(f) { @@ -108,3 +112,19 @@ def write_lock_file(project_root : string; lf : LockFile) : bool { } return ok } + +def read_lock_file(project_root : string; var lf : LockFile) : bool { + return read_lock_file_at(lock_file_path(project_root), lf) +} + +def write_lock_file(project_root : string; lf : LockFile) : bool { + return write_lock_file_at(lock_file_path(project_root), lf) +} + +def read_global_lock_file(var lf : LockFile) : bool { + return read_lock_file_at(global_lock_file_path(), lf) +} + +def write_global_lock_file(lf : LockFile) : bool { + return write_lock_file_at(global_lock_file_path(), lf) +} diff --git a/utils/daspkg/main.das b/utils/daspkg/main.das index 0dc6d3df46..0dd012ac0e 100644 --- a/utils/daspkg/main.das +++ b/utils/daspkg/main.das @@ -36,6 +36,7 @@ def print_usage() { print("\nOptions:\n") print(" --root Project root (default: current directory)\n") print(" --force Force reinstall\n") + print(" --global, -g Operate on global modules (das_root/modules/)\n") print(" --color Enable colored output\n") print(" --no-color Disable colored output\n") print(" --verbose, -v Print detailed progress\n") @@ -55,37 +56,38 @@ def main() { let root = get_full_file_name(parsed.root) log_init(root) let command = parsed.command + let is_global = parsed.global_flag if (command == "list") { - cmd_list(root, parsed.json) + cmd_list(root, parsed.json, is_global) } elif (command == "install") { if (empty(parsed.command_arg)) { - cmd_install_all(root, parsed.force) + cmd_install_all(root, parsed.force, is_global) } else { - cmd_install(root, parsed.command_arg, parsed.force) + cmd_install(root, parsed.command_arg, parsed.force, is_global) } } elif (command == "remove") { if (empty(parsed.command_arg)) { print("Error: remove requires a package name\n") } else { - cmd_remove(root, parsed.command_arg) + cmd_remove(root, parsed.command_arg, is_global) } } elif (command == "update") { if (empty(parsed.command_arg)) { - cmd_update_all(root) + cmd_update_all(root, is_global) } else { - cmd_update(root, parsed.command_arg) + cmd_update(root, parsed.command_arg, is_global) } } elif (command == "upgrade") { if (empty(parsed.command_arg)) { - cmd_upgrade_all(root) + cmd_upgrade_all(root, is_global) } else { - cmd_upgrade(root, parsed.command_arg) + cmd_upgrade(root, parsed.command_arg, is_global) } } elif (command == "build") { - cmd_build(root) + cmd_build(root, is_global) } elif (command == "check") { - cmd_check(root, parsed.json) + cmd_check(root, parsed.json, is_global) } elif (command == "doctor") { cmd_doctor() } elif (command == "search") { diff --git a/utils/daspkg/test_daspkg.das b/utils/daspkg/test_daspkg.das index 299eb131cb..3d32846963 100644 --- a/utils/daspkg/test_daspkg.das +++ b/utils/daspkg/test_daspkg.das @@ -176,8 +176,8 @@ def test_install_local(t : T?) { // verify lock file was written to disk t |> success(fexist("{tmp_root}/{LOCK_FILE_NAME}")) // cleanup - rmdir_rec(tmp_root) - rmdir_rec(tmp_src) + force_rmdir(tmp_root) + force_rmdir(tmp_src) } } @@ -202,7 +202,7 @@ def test_cmd_remove(t : T?) { t |> success(read_lock_file(tmp_root, lf2)) t |> success(!has_package(lf2, "testpkg")) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -356,11 +356,11 @@ def test_cmd_check(t : T?) { write_lock_file(test_root, lf2) cmd_check(test_root) // should print WARNING, not crash t |> success(fexist(pkg2_dir)) - rmdir_rec(pkg2_dir) + force_rmdir(pkg2_dir) } // cleanup - rmdir_rec(test_root) + force_rmdir(test_root) } [test] @@ -694,7 +694,7 @@ def test_log_writes_to_file(t : T?) { } // cleanup - rmdir_rec(test_root) + force_rmdir(test_root) log_file_path = "" } @@ -970,3 +970,139 @@ def test_read_manifest_rich(t : T?) { } } +[test] +def test_parse_args_global_flag(t : T?) { + t |> run("--global flag parsed") @(t : T?) { + let parsed = parse_args(["daslang.exe", "main.das", "--", "install", "foo", "--global"]) + t |> equal(parsed.command, "install") + t |> equal(parsed.command_arg, "foo") + t |> success(parsed.global_flag) + } + t |> run("-g shorthand") @(t : T?) { + let parsed = parse_args(["daslang.exe", "main.das", "--", "-g", "list"]) + t |> equal(parsed.command, "list") + t |> success(parsed.global_flag) + } + t |> run("default global is false") @(t : T?) { + let parsed = parse_args(["daslang.exe", "main.das", "--", "install", "foo"]) + t |> success(!parsed.global_flag) + } +} + +[test] +def test_global_lock_file_roundtrip(t : T?) { + t |> run("write and read global lock file") @(t : T?) { + // use a temp dir instead of the real global lock file path + let tmp_dir = "{get_das_root()}/_daspkg_test_global_lf" + mkdir(tmp_dir) + let path = "{tmp_dir}/{GLOBAL_LOCK_FILE_NAME}" + var lf : LockFile + lf.sdk_version = "0.5.0" + set_package(lf, PackageEntry(name = "globalPkg", source = "github.com/user/globalPkg", + version = "1.0", tag = "v1.0", root = true, global = true)) + t |> success(write_lock_file_at(path, lf)) + // read it back + var lf2 : LockFile + t |> success(read_lock_file_at(path, lf2)) + t |> equal(lf2.sdk_version, "0.5.0") + t |> equal(length(lf2.packages), 1) + let pkg = get_package(lf2, "globalPkg") + t |> equal(pkg.source, "github.com/user/globalPkg") + t |> equal(pkg.tag, "v1.0") + t |> success(pkg.global) + t |> success(pkg.root) + // cleanup + remove(path) + rmdir(tmp_dir) + } +} + +[test] +def test_package_entry_global_field(t : T?) { + t |> run("global field serializes to JSON") @(t : T?) { + let tmp_dir = "{get_das_root()}/_daspkg_test_global_field" + mkdir(tmp_dir) + var lf : LockFile + set_package(lf, PackageEntry(name = "pkg1", source = "github.com/a/pkg1", + version = "1.0", tag = "v1.0", root = true, global = true)) + set_package(lf, PackageEntry(name = "pkg2", source = "github.com/a/pkg2", + version = "2.0", tag = "v2.0", root = true, global = false)) + t |> success(write_lock_file(tmp_dir, lf)) + var lf2 : LockFile + t |> success(read_lock_file(tmp_dir, lf2)) + let p1 = get_package(lf2, "pkg1") + t |> success(p1.global) + let p2 = get_package(lf2, "pkg2") + t |> success(!p2.global) + // cleanup + remove("{tmp_dir}/{LOCK_FILE_NAME}") + rmdir(tmp_dir) + } +} + +[test] +def test_cmd_list_global(t : T?) { + t |> run("lists global packages") @(t : T?) { + let tmp_dir = "{get_das_root()}/_daspkg_test_list_global" + mkdir(tmp_dir) + let path = "{tmp_dir}/{GLOBAL_LOCK_FILE_NAME}" + var lf : LockFile + set_package(lf, PackageEntry(name = "gmod", source = "github.com/a/gmod", + tag = "v1.0", root = true, global = true)) + write_lock_file_at(path, lf) + // cmd_list --global reads the global lock file; we can't easily redirect it + // so just verify the lock file is readable with correct content + var lf2 : LockFile + t |> success(read_lock_file_at(path, lf2)) + t |> equal(length(lf2.packages), 1) + t |> equal(lf2.packages[0].name, "gmod") + t |> success(lf2.packages[0].global) + // cleanup + remove(path) + rmdir(tmp_dir) + } +} + +[test] +def test_cmd_check_global_entry(t : T?) { + t |> run("check verifies global entries at das_root/modules") @(t : T?) { + let tmp_root = "{get_das_root()}/_daspkg_test_check_global" + mkdir(tmp_root) + // create a lock file with a global entry pointing to a non-existent module + var lf : LockFile + set_package(lf, PackageEntry(name = "nonexistent_global", source = "github.com/a/b", + tag = "v1.0", root = true, global = true)) + write_lock_file(tmp_root, lf) + // cmd_check should report it as missing (since das_root/modules/nonexistent_global doesn't exist) + // we can't capture output easily, but we verify the check logic path + cmd_check(tmp_root) + // cleanup + remove("{tmp_root}/{LOCK_FILE_NAME}") + rmdir(tmp_root) + } +} + +[test] +def test_cmd_remove_global_entry(t : T?) { + t |> run("removing global entry only removes lock reference") @(t : T?) { + let tmp_root = "{get_das_root()}/_daspkg_test_rm_global_entry" + mkdir(tmp_root) + // create a lock file with a global entry + var lf : LockFile + set_package(lf, PackageEntry(name = "global_pkg", source = "github.com/a/b", + tag = "v1.0", root = true, global = true)) + write_lock_file(tmp_root, lf) + t |> success(has_package(lf, "global_pkg")) + // remove — should only remove from lock file + cmd_remove(tmp_root, "global_pkg") + var lf2 : LockFile + read_lock_file(tmp_root, lf2) + t |> success(!has_package(lf2, "global_pkg"), "removed from lock file") + // no local directory should have been attempted to delete + // (the module is global, no local dir to delete) + // cleanup + remove("{tmp_root}/{LOCK_FILE_NAME}") + rmdir(tmp_root) + } +} + diff --git a/utils/daspkg/test_daspkg_git.das b/utils/daspkg/test_daspkg_git.das index dfc4177a07..a9cc819b12 100644 --- a/utils/daspkg/test_daspkg_git.das +++ b/utils/daspkg/test_daspkg_git.das @@ -30,7 +30,7 @@ def test_install_git_basic(t : T?) { t |> success(fexist("{tmp_root}/modules/daspkg-test-pure/.das_package")) t |> success(fexist("{tmp_root}/modules/daspkg-test-pure/daslib/test_pure.das")) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -47,7 +47,7 @@ def test_install_git_version_v1(t : T?) { let src = fread("{tmp_root}/modules/daspkg-test-versions/daslib/test_versions.das") t |> success(find(src, "return \"1.0\"") >= 0) t |> success(find(src, "new_in_v2") < 0) - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -66,7 +66,7 @@ def test_install_version_with_v_prefix(t : T?) { // verify v1.0 content let src = fread("{tmp_root}/modules/daspkg-test-versions/daslib/test_versions.das") t |> success(find(src, "return \"1.0\"") >= 0) - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -83,7 +83,7 @@ def test_install_git_version_v2(t : T?) { let src = fread("{tmp_root}/modules/daspkg-test-versions/daslib/test_versions.das") t |> success(find(src, "return \"2.0\"") >= 0) t |> success(find(src, "new_in_v2") >= 0) - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -105,7 +105,7 @@ def test_install_with_dependencies(t : T?) { t |> success(fexist("{tmp_root}/modules/daspkg-test-deps/daslib/test_deps.das")) t |> success(fexist("{tmp_root}/modules/daspkg-test-pure/daslib/test_pure.das")) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -119,7 +119,7 @@ def test_fetch_index(t : T?) { t |> success(length(index) > 0, "index has entries") t |> success(index |> key_exists("daspkg-test-pure"), "test-pure in index") // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -135,7 +135,7 @@ def test_cmd_install_by_name(t : T?) { t |> success(has_package(lf, "daspkg-test-pure")) t |> success(fexist("{tmp_root}/modules/daspkg-test-pure/daslib/test_pure.das")) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -153,7 +153,7 @@ def test_cmd_update_single(t : T?) { // verify still present after update t |> success(fexist("{tmp_root}/modules/daspkg-test-pure/daslib/test_pure.das")) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -183,8 +183,8 @@ def test_cmd_update_recopies_local(t : T?) { let content = fread("{tmp_root}/modules/localpkg/hello.das") t |> equal(content, "// hello v2\n") // cleanup - rmdir_rec(tmp_root) - rmdir_rec(src_dir) + force_rmdir(tmp_root) + force_rmdir(src_dir) } } @@ -199,7 +199,7 @@ def test_cmd_update_nonexistent(t : T?) { cmd_update(tmp_root, "nonexistent") t |> success(true) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -219,7 +219,7 @@ def test_cmd_update_all_integration(t : T?) { t |> success(fexist("{tmp_root}/modules/daspkg-test-pure/daslib/test_pure.das")) t |> success(fexist("{tmp_root}/modules/daspkg-test-deps/daslib/test_deps.das")) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -248,7 +248,7 @@ def test_cmd_update_preserves_version(t : T?) { t |> success(find(src, "\"1.0\"") >= 0) t |> success(find(src, "new_in_v2") < 0) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -266,7 +266,7 @@ def test_cmd_install_all_integration(t : T?) { t |> success(has_package(lf, "daspkg-test-pure")) t |> success(fexist("{tmp_root}/modules/daspkg-test-pure/daslib/test_pure.das")) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -282,7 +282,7 @@ def test_cmd_check_integration(t : T?) { cmd_check(tmp_root) t |> success(fexist("{tmp_root}/modules/daspkg-test-pure/.das_module")) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -300,7 +300,7 @@ def test_cmd_install_already_installed(t : T?) { cmd_install(tmp_root, "github.com/borisbat/daspkg-test-pure", false) t |> success(fexist("{tmp_root}/modules/daspkg-test-pure/daslib/test_pure.das")) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -321,7 +321,7 @@ def test_install_local_auto_build(t : T?) { // verify shared module was produced t |> success(fexist("{tmp_root}/modules/daspkg-example-c/dasModuleFastMath.shared_module")) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -343,7 +343,7 @@ def test_update_local_auto_build(t : T?) { cmd_update(tmp_root, "daspkg-example-c") t |> success(fexist(module_file)) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -399,8 +399,8 @@ def test_full_workflow_local(t : T?) { t |> equal("hi, world", strip(output), "correct output v2") // cleanup - rmdir_rec(tmp_root) - rmdir_rec(pkg_src) + force_rmdir(tmp_root) + force_rmdir(pkg_src) } } @@ -418,14 +418,14 @@ def test_cmd_build_integration(t : T?) { // remove any pre-existing build artifacts let module_file = "{pkg_dir}/dasModuleFastMath.shared_module" if (fexist(module_file)) remove(module_file) - if (fexist("{pkg_dir}/_build")) rmdir_rec("{pkg_dir}/_build") + if (fexist("{pkg_dir}/_build")) force_rmdir("{pkg_dir}/_build") // run cmd_build cmd_build(tmp_root) // verify build happened t |> success(fexist("{pkg_dir}/_build")) t |> success(fexist(module_file)) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -456,7 +456,7 @@ def test_cmd_upgrade_single(t : T?) { t |> success(find(src, "\"2.0\"") >= 0) t |> success(find(src, "new_in_v2") >= 0) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -476,7 +476,7 @@ def test_cmd_upgrade_already_latest(t : T?) { t |> success(read_lock_file(tmp_root, lf2)) t |> equal(get_package(lf2, "daspkg-test-versions").tag, tag_before) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -496,8 +496,8 @@ def test_cmd_upgrade_local_skips(t : T?) { cmd_upgrade(tmp_root, "localpkg") t |> success(has_package(lf, "localpkg")) // cleanup - rmdir_rec(tmp_root) - rmdir_rec(src_dir) + force_rmdir(tmp_root) + force_rmdir(src_dir) } } @@ -520,7 +520,7 @@ def test_cmd_upgrade_all_integration(t : T?) { // daspkg-test-pure should still be present t |> success(has_package(lf2, "daspkg-test-pure")) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -551,7 +551,7 @@ def test_cmd_upgrade_dep_constraint(t : T?) { t |> success(find(src, "\"2.0\"") >= 0) t |> success(find(src, "new_in_v2") >= 0) // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -573,7 +573,7 @@ def test_dependency_cycle_detection(t : T?) { // if we got here, cycle detection worked t |> success(true, "cycle detection prevented infinite recursion") // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) } } @@ -605,6 +605,216 @@ def test_cmd_update_rollback(t : T?) { read_lock_file(tmp_root, lf3) t |> success(has_package(lf3, "daspkg-test-pure"), "lockfile entry restored") // cleanup - rmdir_rec(tmp_root) + force_rmdir(tmp_root) + } +} + +[test] +def test_install_global_basic(t : T?) { + t |> run("installs package globally to das_root/modules") @(t : T?) { + let das_root = get_das_root() + // install v1.0 globally + var glf : LockFile + install_git(das_root, "daspkg-test-versions", "github.com/borisbat/daspkg-test-versions", "1.0", glf, [is_root = true, is_global = true]) + t |> success(has_package(glf, "daspkg-test-versions")) + t |> equal(get_package(glf, "daspkg-test-versions").tag, "v1.0") + t |> success(get_package(glf, "daspkg-test-versions").global) + t |> success(fexist("{das_root}/modules/daspkg-test-versions/.das_module")) + // verify global lock file was written + var glf2 : LockFile + t |> success(read_global_lock_file(glf2)) + t |> success(has_package(glf2, "daspkg-test-versions")) + // cleanup + force_rmdir("{das_root}/modules/daspkg-test-versions") + remove(global_lock_file_path()) + } +} + +[test] +def test_local_auto_uses_global(t : T?) { + t |> run("local install uses compatible global version") @(t : T?) { + let das_root = get_das_root() + let tmp_root = "{das_root}/_daspkg_test_auto_global" + mkdir(tmp_root) + // install v1.0 globally + var glf : LockFile + install_git(das_root, "daspkg-test-versions", "github.com/borisbat/daspkg-test-versions", "1.0", glf, [is_root = true, is_global = true]) + // local install with no version constraint — should auto-use global + cmd_install(tmp_root, "github.com/borisbat/daspkg-test-versions", false) + var lf : LockFile + t |> success(read_lock_file(tmp_root, lf)) + t |> success(has_package(lf, "daspkg-test-versions")) + let entry = get_package(lf, "daspkg-test-versions") + t |> success(entry.global, "entry should be marked global") + t |> equal(entry.tag, "v1.0") + // verify no local directory was created + t |> success(!fexist("{tmp_root}/modules/daspkg-test-versions"), "no local copy") + // cleanup + force_rmdir(tmp_root) + force_rmdir("{das_root}/modules/daspkg-test-versions") + remove(global_lock_file_path()) + } +} + +[test] +def test_global_version_mismatch(t : T?) { + t |> run("local install errors on version mismatch with global") @(t : T?) { + let das_root = get_das_root() + let tmp_root = "{das_root}/_daspkg_test_ver_mismatch" + mkdir(tmp_root) + // install v2.0 globally + var glf : LockFile + install_git(das_root, "daspkg-test-versions", "github.com/borisbat/daspkg-test-versions", "2.0", glf, [is_root = true, is_global = true]) + // local install requesting v1.0 — should NOT auto-use global (version mismatch) + cmd_install(tmp_root, "github.com/borisbat/daspkg-test-versions@1.0", false) + var lf : LockFile + read_lock_file(tmp_root, lf) + // should NOT have installed anything (error, no --force) + t |> success(!has_package(lf, "daspkg-test-versions"), "should not install on mismatch") + // cleanup + force_rmdir(tmp_root) + force_rmdir("{das_root}/modules/daspkg-test-versions") + remove(global_lock_file_path()) + } +} + +[test] +def test_global_version_mismatch_force(t : T?) { + t |> run("--force installs locally despite global version mismatch") @(t : T?) { + let das_root = get_das_root() + let tmp_root = "{das_root}/_daspkg_test_force_local" + mkdir(tmp_root) + // install v2.0 globally + var glf : LockFile + install_git(das_root, "daspkg-test-versions", "github.com/borisbat/daspkg-test-versions", "2.0", glf, [is_root = true, is_global = true]) + // local install requesting v1.0 with --force — should install locally + cmd_install(tmp_root, "github.com/borisbat/daspkg-test-versions@1.0", true) + var lf : LockFile + t |> success(read_lock_file(tmp_root, lf)) + t |> success(has_package(lf, "daspkg-test-versions"), "should install locally with --force") + let entry = get_package(lf, "daspkg-test-versions") + t |> success(!entry.global, "should NOT be global") + t |> equal(entry.tag, "v1.0", "should be v1.0") + t |> success(fexist("{tmp_root}/modules/daspkg-test-versions"), "local directory exists") + // cleanup + force_rmdir(tmp_root) + force_rmdir("{das_root}/modules/daspkg-test-versions") + remove(global_lock_file_path()) + } +} + +[test] +def test_remove_global_reference(t : T?) { + t |> run("removing global reference doesn't delete global directory") @(t : T?) { + let das_root = get_das_root() + let tmp_root = "{das_root}/_daspkg_test_rm_ref" + mkdir(tmp_root) + // install globally + var glf : LockFile + install_git(das_root, "daspkg-test-versions", "github.com/borisbat/daspkg-test-versions", "1.0", glf, [is_root = true, is_global = true]) + // local install auto-uses global + cmd_install(tmp_root, "github.com/borisbat/daspkg-test-versions", false) + var lf : LockFile + read_lock_file(tmp_root, lf) + t |> success(has_package(lf, "daspkg-test-versions")) + // remove from project (not --global) + cmd_remove(tmp_root, "daspkg-test-versions") + var lf2 : LockFile + read_lock_file(tmp_root, lf2) + t |> success(!has_package(lf2, "daspkg-test-versions"), "removed from project lock") + // global directory should still exist + t |> success(fexist("{das_root}/modules/daspkg-test-versions"), "global dir intact") + // cleanup + force_rmdir(tmp_root) + force_rmdir("{das_root}/modules/daspkg-test-versions") + remove(global_lock_file_path()) + } +} + +[test] +def test_install_all_version_constraint(t : T?) { + t |> run("cmd_install_all passes version constraint to global check") @(t : T?) { + let das_root = get_das_root() + let tmp_root = "{das_root}/_daspkg_test_constraint" + mkdir(tmp_root) + // install v2.0 globally + var glf : LockFile + install_git(das_root, "daspkg-test-versions", "github.com/borisbat/daspkg-test-versions", "2.0", glf, [is_root = true, is_global = true]) + // create a .das_package that requires =1.0 + fopen("{tmp_root}/.das_package", "wb") <| $(f) { + fwrite(f, "options gen2\nrequire daslib/daspkg\n\n[export]\ndef package() \{\n package_name(\"test\")\n package_description(\"test\")\n\}\n\n[export]\ndef dependencies(version : string) \{\n require_package(\"daspkg-test-versions\", \"=1.0\")\n\}\n") + } + // cmd_install_all should detect mismatch and NOT install + cmd_install_all(tmp_root, false) + var lf : LockFile + read_lock_file(tmp_root, lf) + t |> success(!has_package(lf, "daspkg-test-versions"), "should not auto-use v2.0 when =1.0 required") + // cleanup + force_rmdir(tmp_root) + force_rmdir("{das_root}/modules/daspkg-test-versions") + remove(global_lock_file_path()) + } +} + +[test] +def test_remove_global_package(t : T?) { + t |> run("remove --global deletes directory and lock entry") @(t : T?) { + let das_root = get_das_root() + // install globally + var glf : LockFile + install_git(das_root, "daspkg-test-versions", "github.com/borisbat/daspkg-test-versions", "1.0", glf, [is_root = true, is_global = true]) + t |> success(fexist("{das_root}/modules/daspkg-test-versions")) + // remove globally + cmd_remove(das_root, "daspkg-test-versions", [is_global = true]) + t |> success(!fexist("{das_root}/modules/daspkg-test-versions"), "directory removed") + var glf2 : LockFile + read_global_lock_file(glf2) + t |> success(!has_package(glf2, "daspkg-test-versions"), "lock entry removed") + // cleanup + remove(global_lock_file_path()) + } +} + +[test] +def test_update_global_package(t : T?) { + t |> run("update --global re-installs at pinned version") @(t : T?) { + let das_root = get_das_root() + // install v1.0 globally + var glf : LockFile + install_git(das_root, "daspkg-test-versions", "github.com/borisbat/daspkg-test-versions", "1.0", glf, [is_root = true, is_global = true]) + t |> equal(get_package(glf, "daspkg-test-versions").tag, "v1.0") + // update — should re-install v1.0 (pinned version) + cmd_update(das_root, "daspkg-test-versions", [is_global = true]) + var glf2 : LockFile + read_global_lock_file(glf2) + t |> success(has_package(glf2, "daspkg-test-versions"), "still installed after update") + t |> equal(get_package(glf2, "daspkg-test-versions").tag, "v1.0", "still v1.0") + // cleanup + force_rmdir("{das_root}/modules/daspkg-test-versions") + remove(global_lock_file_path()) + } +} + +[test] +def test_local_update_rejects_global_entry(t : T?) { + t |> run("update without --global rejects global entry") @(t : T?) { + let das_root = get_das_root() + let tmp_root = "{das_root}/_daspkg_test_upd_reject" + mkdir(tmp_root) + // install globally + var glf : LockFile + install_git(das_root, "daspkg-test-versions", "github.com/borisbat/daspkg-test-versions", "1.0", glf, [is_root = true, is_global = true]) + // local install auto-uses global + cmd_install(tmp_root, "github.com/borisbat/daspkg-test-versions", false) + // try update without --global — should refuse + cmd_update(tmp_root, "daspkg-test-versions") + // verify nothing changed — still global reference + var lf : LockFile + read_lock_file(tmp_root, lf) + t |> success(get_package(lf, "daspkg-test-versions").global, "still global reference") + // cleanup + force_rmdir(tmp_root) + force_rmdir("{das_root}/modules/daspkg-test-versions") + remove(global_lock_file_path()) } } diff --git a/utils/daspkg/utils.das b/utils/daspkg/utils.das index 9bfd11394a..30402a63ff 100644 --- a/utils/daspkg/utils.das +++ b/utils/daspkg/utils.das @@ -78,6 +78,21 @@ def run_cmd(cmd : string; var output : string&) : int { return exit_code } +// force-remove a directory tree, handling read-only files (e.g. git objects on Windows) +def force_rmdir(path : string) { + if (!fexist(path)) { + return + } + // rmdir_rec can't remove read-only files (git objects on Windows) + // use shell rm -rf which handles this correctly + var output : string + let exit_code = run_cmd("rm -rf \"{path}\"", output) + if (exit_code != 0) { + // fallback: try builtin rmdir_rec + rmdir_rec(path) + } +} + def package_name_from_source(source : string) : string { // "github.com/user/dasImgui" -> "dasImgui" // "/path/to/mymod" -> "mymod"