diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ede5fd..b490b30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: - name: Install xlings env: - XLINGS_VERSION: 0.4.69 + XLINGS_VERSION: 2026.8.4.2 run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -23,7 +23,7 @@ jobs: "/tmp/xlings-${XLINGS_VERSION}-linux-x86_64/subos/default/bin/xlings" self install echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - - name: Install workspace tools (.xlings.json → mcpp 0.0.109) + - name: Install workspace tools (.xlings.json → mcpp 2026.8.4.1) run: xlings install -y # Cache mcpp's self-bootstrapped sandbox (musl-gcc + binutils + @@ -32,9 +32,11 @@ jobs: - name: Cache mcpp sandbox uses: actions/cache@v4 with: - path: ~/.xlings/data/xpkgs/xim-x-mcpp/0.0.109/registry - key: mcpp-sandbox-${{ runner.os }}-mcpp0.0.109 + path: ~/.xlings/data/xpkgs/xim-x-mcpp/2026.8.4.1/registry + key: mcpp-sandbox-${{ runner.os }}-mcpp2026.8.4.1 + # build.mcpp regenerates the embedded Lua stdlib from src/lua-stdlib/ + # into MCPP_OUT_DIR. There is no committed copy to fall out of sync with. - name: Build with mcpp run: mcpp build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 278d91b..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,152 +0,0 @@ -name: Release - -on: - workflow_dispatch: - inputs: - version: - description: "Release version (e.g., 0.1.1)" - required: true - type: string - -jobs: - build-linux: - runs-on: ubuntu-latest - timeout-minutes: 25 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup xmake - uses: xmake-io/github-action-setup-xmake@v1 - with: - xmake-version: latest - - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y build-essential - - - name: Install Xlings - run: curl -fsSL https://d2learn.org/xlings-install.sh | bash - - - name: Install GCC 15.1 with Xlings - run: | - export PATH=/home/xlings/.xlings_data/bin:$PATH - xlings install gcc@15.1 -y - - - name: Build with xmake - run: | - export PATH=/home/xlings/.xlings_data/bin:$PATH - xmake f -m release -y -vv - xmake -y -vv -j$(nproc) - - - name: Run tests - run: | - export PATH=/home/xlings/.xlings_data/bin:$PATH - xmake run templates_test - - - name: Create release package - run: | - PKG="mcpplibs-templates-${{ inputs.version }}-linux-x86_64" - mkdir -p "$PKG" - cp build/linux/x86_64/release/libmcpplibs-templates.a "$PKG/" - cp src/templates.cppm README.md LICENSE "$PKG/" - tar -czf "${PKG}.tar.gz" "$PKG" - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: templates-linux-x86_64 - path: mcpplibs-templates-${{ inputs.version }}-linux-x86_64.tar.gz - - build-macos: - runs-on: macos-14 - timeout-minutes: 25 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install dependencies - run: brew install xmake llvm@20 - - - name: Build with xmake (LLVM 20) - run: | - export PATH=/opt/homebrew/opt/llvm@20/bin:$PATH - xmake f -p macosx -m release --toolchain=llvm --sdk=/opt/homebrew/opt/llvm@20 -y -vv - xmake -y -vv -j$(sysctl -n hw.ncpu) - - - name: Create release package - run: | - PKG="mcpplibs-templates-${{ inputs.version }}-macosx-arm64" - mkdir -p "$PKG" - cp build/macosx/arm64/release/libmcpplibs-templates.a "$PKG/" - cp src/templates.cppm README.md LICENSE "$PKG/" - tar -czf "${PKG}.tar.gz" "$PKG" - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: templates-macosx-arm64 - path: mcpplibs-templates-${{ inputs.version }}-macosx-arm64.tar.gz - - build-windows: - runs-on: windows-latest - timeout-minutes: 25 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup xmake - uses: xmake-io/github-action-setup-xmake@v1 - with: - xmake-version: latest - - - name: Build with xmake - run: | - xmake f -m release -y -vv - xmake -y -vv -j$env:NUMBER_OF_PROCESSORS - - - name: Run tests - run: xmake run templates_test - - - name: Create release package - shell: pwsh - run: | - $pkg = "mcpplibs-templates-${{ inputs.version }}-windows-x86_64" - New-Item -ItemType Directory -Path $pkg -Force | Out-Null - Copy-Item "build\windows\x64\release\mcpplibs-templates.lib" -Destination "$pkg\" - Copy-Item "src\templates.cppm" -Destination "$pkg\" - Copy-Item "README.md" -Destination "$pkg\" - Copy-Item "LICENSE" -Destination "$pkg\" - Compress-Archive -Path $pkg -DestinationPath "$pkg.zip" - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: templates-windows-x86_64 - path: mcpplibs-templates-${{ inputs.version }}-windows-x86_64.zip - - create-release: - needs: [build-linux, build-macos, build-windows] - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts - - - name: Create Release - uses: softprops/action-gh-release@v1 - with: - tag_name: v${{ inputs.version }} - name: ${{ inputs.version }} - draft: false - prerelease: false - files: | - artifacts/templates-linux-x86_64/mcpplibs-templates-${{ inputs.version }}-linux-x86_64.tar.gz - artifacts/templates-macosx-arm64/mcpplibs-templates-${{ inputs.version }}-macosx-arm64.tar.gz - artifacts/templates-windows-x86_64/mcpplibs-templates-${{ inputs.version }}-windows-x86_64.zip - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 2b3bde4..fbc0199 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ -# xmake -.xmake +# build output build target/ compile_commands.json diff --git a/.xlings.json b/.xlings.json index a7b8d67..3220145 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": { "linux": "0.0.109" } + "mcpp": { "linux": "2026.8.4.1" } } } diff --git a/CMakeLists.txt b/CMakeLists.txt deleted file mode 100644 index bd594ce..0000000 --- a/CMakeLists.txt +++ /dev/null @@ -1,30 +0,0 @@ -cmake_minimum_required(VERSION 4.0.2) - -# https://github.com/Kitware/CMake/blob/master/Help/dev/experimental.rst -set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "a9e1cf81-9932-4810-974b-6eccaf14e457") -set(CMAKE_CXX_STANDARD 23) -set(CMAKE_CXX_MODULE_STD 1) - -project(mcpplibs-templates VERSION 1.0.0 LANGUAGES CXX) - -# Library -add_library(mcpplibs-templates STATIC) - -file(GLOB_RECURSE MODULE_SOURCES "src/*.cppm") - -target_sources(mcpplibs-templates - PUBLIC - FILE_SET CXX_MODULES FILES - ${MODULE_SOURCES} -) - -# Test -add_executable(templates_test tests/main.cpp) -target_link_libraries(templates_test PRIVATE mcpplibs-templates) - -enable_testing() -add_test(NAME mcpplibs-templates-test COMMAND templates_test) - -# Example -add_executable(basic examples/basic.cpp) -target_link_libraries(basic PRIVATE mcpplibs-templates) diff --git a/README.md b/README.md index 2636040..ba2213e 100644 --- a/README.md +++ b/README.md @@ -78,88 +78,62 @@ libxpkg/ │ ├── test_model.cpp │ ├── test_loader.cppm │ ├── test_index.cpp -│ ├── test_executor.cpp -│ └── xmake.lua +│ └── test_executor.cpp ├── examples/ │ ├── basic.cpp # 最小示例 -│ ├── lifecycle.cpp # 完整生命周期演示 -│ └── xmake.lua +│ └── lifecycle.cpp # 完整生命周期演示 +├── build.mcpp # 构建期由 lua-stdlib/ 生成嵌入式 Lua stdlib ├── .agents/ │ ├── docs/ # 开发问题排查报告 │ ├── plans/ # 设计与实现方案 │ └── skills/ # 项目最佳实践 -├── xmake.lua -└── CMakeLists.txt +└── mcpp.toml ``` ## 构建与测试 -**前置条件**:GCC 15+ 或 Clang 20+(需支持 C++23 模块) +本库由 **mcpp** 构建。mcpp 自带受管沙箱(工具链由 `mcpp.toml` 固定), +不需要手工准备编译器,也不再有 xmake / CMake 构建入口。 ```bash -# Linux(通过 xlings 安装 GCC 15) -xlings install gcc@15.1 -y -xmake f -m release -y -xmake - -# macOS(通过 Homebrew 安装 LLVM 20) -brew install llvm@20 -export PATH=/opt/homebrew/opt/llvm@20/bin:$PATH -xmake f --toolchain=llvm --sdk=/opt/homebrew/opt/llvm@20 -m release -y -xmake +xlings install -y # 装 .xlings.json 里固定的 mcpp +mcpp build +mcpp test ``` -**运行测试** +macOS 专用的 elfpatch 用例需要 `install_name_tool`,在 Linux 上跑不了, +CI 里按名字过滤: ```bash -xmake run xpkg_model_test -xmake run xpkg_index_test -xmake run xpkg_loader_test -xmake run xpkg_executor_test +mcpp test -- --gtest_filter=-ExecutorTest.ApplyElfpatchAuto_* ``` -**运行示例** +**Lua stdlib 的生成** -```bash -xmake run basic -xmake run lifecycle -``` - -## CI/CD +`src/lua-stdlib/**.lua` 是唯一的源码。它需要以裸字符串字面量的形式进二进制, +这一步由 `build.mcpp` 在构建期完成:生成的 `xpkg-lua-stdlib.cppm` 写进 +`MCPP_OUT_DIR`,通过 `generated=` 交给构建,**不进版本库**。 -GitHub Actions 在三个平台上自动构建和测试: +所以改完 `.lua` 直接 `mcpp build` 即可,没有"忘记重新生成"这回事 —— +仓库里只有一份拷贝,两份拷贝对不上这种状态不存在。 -| 平台 | 工具链 | 状态 | -|------|--------|------| -| Linux (ubuntu-latest) | GCC 15.1 via Xlings | [![Linux](https://github.com/Sunrisepeak/libxpkg/actions/workflows/ci.yml/badge.svg)](https://github.com/Sunrisepeak/libxpkg/actions/workflows/ci.yml) | -| macOS 14 | LLVM 20 via Homebrew | [![macOS](https://github.com/Sunrisepeak/libxpkg/actions/workflows/ci.yml/badge.svg)](https://github.com/Sunrisepeak/libxpkg/actions/workflows/ci.yml) | -| Windows (latest) | MSVC via xmake | [![Windows](https://github.com/Sunrisepeak/libxpkg/actions/workflows/ci.yml/badge.svg)](https://github.com/Sunrisepeak/libxpkg/actions/workflows/ci.yml) | +(它曾经存在过:0.0.47 的 `xvm.files` 只改了生成物,源 `.lua` 一直没有。) -## xmake 集成 +## CI/CD -**引入全部模块**(推荐): +GitHub Actions 在 Linux 上构建并测试,工具链由 mcpp 沙箱提供: -```lua -add_repositories("mcpplibs-index https://github.com/mcpplibs/mcpplibs-index.git") -add_requires("mcpplibs-xpkg") +| 平台 | 构建 | 状态 | +|------|------|------| +| Linux (ubuntu-latest) | mcpp(沙箱内 GCC) | [![CI](https://github.com/openxlings/libxpkg/actions/workflows/ci.yml/badge.svg)](https://github.com/openxlings/libxpkg/actions/workflows/ci.yml) | -target("myapp") - set_kind("binary") - set_languages("c++23") - add_files("main.cpp") - add_deps("xpkg") -- 聚合 target,包含所有子模块 - set_policy("build.c++.modules", true) -``` +## 作为依赖引入 -**按需引入单个子模块**: +在使用方的 `mcpp.toml` 里声明: -```lua -target("myapp") - set_kind("binary") - set_languages("c++23") - add_files("main.cpp") - add_deps("mcpplibs-xpkg", "mcpplibs-xpkg-loader") -- 仅引入数据模型 + 加载器 - set_policy("build.c++.modules", true) +```toml +[dependencies] +"mcpplibs.xpkg" = "0.0.48" ``` ## 相关链接 diff --git a/build.mcpp b/build.mcpp new file mode 100644 index 0000000..d983c9e --- /dev/null +++ b/build.mcpp @@ -0,0 +1,145 @@ +// build.mcpp — generate the embedded Lua stdlib. +// +// The Lua stdlib lives under src/lua-stdlib/ as readable modules, and has to +// reach the binary as raw-string literals. This program is the only thing that +// turns one into the other: the generated .cppm is written into MCPP_OUT_DIR +// and handed to the build with `generated=`, so there is exactly one copy of +// each module under version control and drift between the two is not a state +// the repository can be in. +// +// It has been in that state. `xvm.files` shipped in 0.0.47 as an edit to the +// generated file alone; the .lua that is nominally its source never had it. An +// earlier xmake before_build hook would have regenerated from that stale source +// and silently deleted the feature from the shipped stdlib. Nothing checked, +// because a build that reads neither copy cannot notice they disagree. +// +// Writes go to MCPP_OUT_DIR, never the package root: as a registry dependency +// this package's root is shared between projects and may be read-only. + +import std; +import mcpp; + +namespace fs = std::filesystem; + +namespace { + +// MSVC C2026 caps a string literal at 16380 bytes. Chunking is not optional +// even though CI is Linux-only -- xlings consumes this library on Windows. +constexpr std::size_t MSVC_LIMIT = 15000; + +struct Module { + const char* var; + const char* path; // const char*, not string_view: mcpp's directive API +}; // takes const char*, and .data() on a view is a trap + +// Order is the load order in xpkg-executor.cppm's load_stdlib. +constexpr Module MODULES[] = { + {"prelude_lua", "src/lua-stdlib/prelude.lua"}, + {"log_lua", "src/lua-stdlib/xim/libxpkg/log.lua"}, + {"pkginfo_lua", "src/lua-stdlib/xim/libxpkg/pkginfo.lua"}, + {"system_lua", "src/lua-stdlib/xim/libxpkg/system.lua"}, + {"subos_lua", "src/lua-stdlib/xim/libxpkg/subos.lua"}, + {"xvm_lua", "src/lua-stdlib/xim/libxpkg/xvm.lua"}, + {"utils_lua", "src/lua-stdlib/xim/libxpkg/utils.lua"}, + {"pkgmanager_lua", "src/lua-stdlib/xim/libxpkg/pkgmanager.lua"}, + {"elfpatch_lua", "src/lua-stdlib/xim/libxpkg/elfpatch.lua"}, + {"json_lua", "src/lua-stdlib/xim/libxpkg/json.lua"}, + {"base64_lua", "src/lua-stdlib/xim/libxpkg/base64.lua"}, +}; + +[[noreturn]] void fail(std::string_view what, const fs::path& p) { + // stdout, not stderr: stderr can interleave into the buffered directive + // stream. A non-zero exit is what aborts the build. + std::println("libxpkg build.mcpp: {} {}", what, p.string()); + std::exit(1); +} + +std::string slurp(const fs::path& p) { + std::ifstream f(p, std::ios::binary); + if (!f) fail("cannot read", p); + std::ostringstream ss; + ss << f.rdbuf(); + return std::move(ss).str(); +} + +// One literal, wrapped in its own newlines. Reassembly therefore gains a blank +// line at every seam -- inert in Lua, and the reason chunk boundaries are +// allowed to move without anything downstream caring. +void emit_literal(std::string& out, std::string_view name, std::string_view body) { + out += "inline constexpr std::string_view "; + out += name; + out += " = R\"__LUA__(\n"; + out += body; + out += "\n)__LUA__\";\n\n"; +} + +void embed(std::string& out, std::string_view var, const std::string& content) { + if (content.size() <= MSVC_LIMIT) { + emit_literal(out, var, content); + return; + } + + std::vector chunks; + std::size_t pos = 0; + for (int idx = 0; pos < content.size(); ++idx) { + auto chunk = std::string_view{content}.substr( + pos, std::min(MSVC_LIMIT, content.size() - pos)); + // Pull the boundary back to the last line break so a split never lands + // mid-line; a raw string would keep it valid either way, but a diff of + // the generated file stays readable this way. + if (pos + MSVC_LIMIT < content.size()) { + if (auto nl = chunk.find_last_of('\n'); nl != std::string_view::npos) + chunk = chunk.substr(0, nl); + } + auto name = std::format("{}_{}", var, idx); + emit_literal(out, name, chunk); + chunks.push_back(std::move(name)); + pos += chunk.size(); + } + + out += std::format("inline const std::string {}_storage = []{{ std::string s;\n", var); + for (const auto& c : chunks) out += std::format(" s += {};\n", c); + out += " return s; }();\n"; + out += std::format("inline const std::string_view {} = {}_storage;\n\n", var, var); +} + +} // namespace + +int main() { + const fs::path root = mcpp::manifest_dir(); + const fs::path out = fs::path(mcpp::out_dir()) / "xpkg-lua-stdlib.cppm"; + + std::string text = + "// Generated by build.mcpp from src/lua-stdlib/ — do not edit.\n" + "// This file is not under version control; edit the .lua sources.\n" + "module;\n" + "export module mcpplibs.xpkg.lua_stdlib;\n" + "import std;\n\n" + "export namespace mcpplibs::xpkg::detail {\n\n"; + + for (const auto& m : MODULES) { + const auto src = root / m.path; + embed(text, m.var, slurp(src)); + // Declared input: without this the program is re-run only when it or + // the toolchain changes, and a .lua edit would not reach the binary. + mcpp::rerun_if_changed(m.path); + } + text += "} // namespace mcpplibs::xpkg::detail\n"; + + { + std::error_code ec; + fs::create_directories(out.parent_path(), ec); + std::ofstream f(out, std::ios::binary); + if (!f) fail("cannot write", out); + f << text; + if (!f) fail("write failed for", out); + } + + // Absolute, deliberately. A relative `generated=` resolves against the + // project root for the root package but against MCPP_OUT_DIR for a + // dependency's build.mcpp, and this program has to be right in both roles + // -- xlings consumes libxpkg from the registry. + mcpp::generated(out.string().c_str()); + mcpp::rerun_if_changed("build.mcpp"); + return 0; +} diff --git a/docs/architecture.md b/docs/architecture.md index f6d12f6..a9b5ec6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -97,7 +97,10 @@ graph TD #### Lua 运行时兼容层 -executor 通过 `prelude.lua`(编译时嵌入 `xpkg-lua-stdlib.cppm`)为包脚本提供 xmake 兼容的运行环境。xpkg V1 规范的包脚本沿用了 xmake 的编程约定,因此 executor 需要兼容这些约定: +executor 通过 `prelude.lua`(编译时嵌入 `xpkg-lua-stdlib.cppm`)为包脚本提供运行环境。 +这里的"兼容"只涉及**包脚本的 Lua API 约定** —— xpkg V1 规范成型时沿用了 xmake 的写法, +存量 recipe 依赖这些名字,因此 executor 必须提供同名实现。libxpkg 自身的构建不依赖 xmake +(构建入口只有 `mcpp`),这两件事没有关系: | 类别 | 兼容项 | 说明 | |------|--------|------| @@ -131,20 +134,18 @@ libxpkg/ │ ├── main.cpp │ ├── test_loader.cpp │ ├── test_index.cpp -│ ├── test_executor.cpp -│ └── xmake.lua +│ └── test_executor.cpp ├── examples/ │ ├── basic.cpp # 加载包元数据示例 -│ ├── index_demo.cpp # 构建并搜索索引示例 -│ └── xmake.lua +│ └── index_demo.cpp # 构建并搜索索引示例 +├── build.mcpp # 构建期由 lua-stdlib/ 生成嵌入式 Lua stdlib ├── docs/ │ └── architecture.md # 本文档 ├── .agents/ │ ├── skills/ │ └── plans/ │ └── 2026-03-01-libxpkg-design.md # 完整设计方案 -├── xmake.lua -├── CMakeLists.txt +├── mcpp.toml # 唯一构建入口(mcpp build / mcpp test) └── README.md ``` diff --git a/examples/xmake.lua b/examples/xmake.lua deleted file mode 100644 index 4df67c0..0000000 --- a/examples/xmake.lua +++ /dev/null @@ -1,21 +0,0 @@ -add_rules("mode.debug", "mode.release") - -set_languages("c++23") - -target("basic") - set_kind("binary") - add_files("basic.cpp") - add_deps("mcpplibs-xpkg") - set_policy("build.c++.modules", true) - --- Full lifecycle demo: load → index → search → executor → install → uninstall -target("lifecycle") - set_kind("binary") - add_files("lifecycle.cpp") - add_deps("xpkg") - set_policy("build.c++.modules", true) - on_config(function(target) - local dir = path.join(os.projectdir(), "tests", "fixtures") - dir = dir:gsub("\\", "/") - target:add("defines", 'XPKG_FIXTURES_DIR="' .. dir .. '"') - end) diff --git a/mcpp.lock b/mcpp.lock index c9949ac..9a6c268 100644 --- a/mcpp.lock +++ b/mcpp.lock @@ -6,3 +6,4 @@ namespace = "mcpplibs.capi" version = "0.0.3" source = "index+mcpplibs.capi@0.0.3" hash = "fnv1a:c8e7ef9d60a1a3a6" + diff --git a/mcpp.toml b/mcpp.toml index 8d4ef00..753fb8a 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "xpkg" -version = "0.0.47" +version = "0.0.48" description = "C++23 reference implementation of the xpkg V2 spec (multi-arch)" license = "Apache-2.0" repo = "https://github.com/openxlings/libxpkg" diff --git a/src/lua-stdlib/xim/libxpkg/subos.lua b/src/lua-stdlib/xim/libxpkg/subos.lua new file mode 100644 index 0000000..bee8eb4 --- /dev/null +++ b/src/lua-stdlib/xim/libxpkg/subos.lua @@ -0,0 +1,100 @@ +-- xim.libxpkg.subos: subos manifest declarations (collects ops for C++ processing) +-- +-- Slice 1 surface is a single entry point, `subos.env{}`: a package declares an +-- environment variable that its subos must set before user programs run. xlings +-- records the declaration in the subos manifest under the declaring package's +-- binding, expands it at activation time, and drops the whole section when that +-- package is uninstalled -- so a recipe never writes cleanup code for it. +-- +-- This exists because PATH and RPATH do not cover every discovery protocol. A +-- GL driver is found through LIBGL_DRIVERS_PATH, an EGL vendor through +-- __EGL_VENDOR_LIBRARY_DIRS, a font config through XDG_DATA_DIRS -- none of +-- which any amount of linking can supply. +-- +-- CAPABILITY PROBE -- recipes MUST write: +-- +-- if type(subos.env) == "function" then ... end +-- +-- and NOT `if subos.env then`. import() hands back a permissive proxy stub for +-- a module the running client does not ship, and every key read off that stub +-- is a truthy callable table. `if subos.env then` is therefore true on every +-- client, including the older ones that will accept the call and silently +-- discard it. type() separates them: the stub is a `table` carrying a __call +-- metamethod, the real entry point is a `function`. +-- +-- The rule differs from `if xvm.files then` in the V2 spec only because `xvm` +-- is a module older clients already ship -- there the missing *field* really is +-- nil. A missing *module* never is. + +local M = {} + +local function _get_log() + return _LIBXPKG_MODULES and _LIBXPKG_MODULES["log"] +end + +_XVM_OPS = _XVM_OPS or {} + +-- Slice 1 implements `set` and `prepend`. `set-if-unset` and `append` are +-- deliberately absent rather than silently accepted: a recipe that asks for one +-- gets told, instead of getting `set` and a surprise. +local _VALID_MODES = { set = true, prepend = true } + +--- Declare an environment variable for the subos this package installs into. +-- @param opt table: +-- var variable name (required) +-- op "set" | "prepend" (default "set") +-- value value; may contain ${pkgdir} / ${subosdir} / ${home} / +-- ${xlings_home}, expanded by xlings at activation time. Use them -- +-- a literal absolute path pins the manifest to one machine. +-- binding provider identity "@"; defaults to this package's +-- @return true when the declaration was recorded +function M.env(opt) + opt = opt or {} + local log = _get_log() + + local var = opt.var or "" + if var == "" then + if log then log.warn("subos.env: missing 'var', declaration ignored") end + return false + end + + local mode = opt.op or "set" + if not _VALID_MODES[mode] then + if log then + log.warn("subos.env: unsupported op '%s' for %s " + .. "(this client implements set|prepend), declaration ignored", + tostring(mode), var) + end + return false + end + + local binding = opt.binding or "" + if binding == "" and _RUNTIME then + local n, v = _RUNTIME.pkg_name, _RUNTIME.version + if n and n ~= "" and v and v ~= "" then binding = n .. "@" .. v end + end + if binding == "" then + if log then + log.warn("subos.env: cannot determine the declaring package for %s; " + .. "pass binding = \"@\"", var) + end + return false + end + + -- `op` is the category the C++ side dispatches on, and it is already taken + -- by the time this entry is read. The caller's set/prepend choice travels + -- as `mode` so the two names never collide. + table.insert(_XVM_OPS, { + op = "subos_env", + var = var, + mode = mode, + value = opt.value or "", + binding = binding, + }) + if log then + log.debug("subos env %s %s=%s (%s)", mode, var, opt.value or "", binding) + end + return true +end + +return M diff --git a/src/lua-stdlib/xim/libxpkg/xvm.lua b/src/lua-stdlib/xim/libxpkg/xvm.lua index 7959b32..4f05634 100644 --- a/src/lua-stdlib/xim/libxpkg/xvm.lua +++ b/src/lua-stdlib/xim/libxpkg/xvm.lua @@ -7,6 +7,30 @@ end _XVM_OPS = _XVM_OPS or {} +--- Register one entry with the version manager. +-- @param name target name +-- @param opt table with: +-- type "program" (default) | "lib" | "files" +-- version defaults to the package version +-- bindir directory holding the artifact (default: install_dir) +-- filename artifact name inside bindir +-- alias name it is exposed under +-- binding "@" -- which release this belongs to +-- args list of arguments injected ahead of the user's own when the +-- shim dispatches. Use this rather than appending to `alias`: +-- consumers split the alias on its first space, so a path with +-- one in it breaks, and every reader of `alias` then shows a +-- command line where a name belongs. +-- envs environment variables +-- +-- For type = "files", the entry describes an asset placed into the subos +-- instead of an artifact to dispatch: +-- src source, relative to the payload root +-- dst destination, relative to the subos root +-- +-- Both must be relative. A payload is shared between subos and +-- reference-counted, so an absolute destination recorded against it would +-- be correct for exactly one subos and wrong for the rest. function M.add(name, opt) opt = opt or {} local entry = { @@ -18,6 +42,9 @@ function M.add(name, opt) type = opt.type or "", filename = opt.filename or "", binding = opt.binding or "", + src = opt.src or "", + dst = opt.dst or "", + args = opt.args or nil, envs = opt.envs or nil, } local log = _get_log() @@ -25,6 +52,38 @@ function M.add(name, opt) table.insert(_XVM_OPS, entry) end +--- Declare an asset this package places into the subos. +-- +-- Sugar over `M.add` for the case where the entry is a file rather than +-- something to dispatch, so the caller does not have to invent a target +-- name: one is derived from the package name. A release may declare several, +-- and each call adds one. +-- +-- @param opt src / dst (both relative, see M.add), plus binding +function M.files(opt) + opt = opt or {} + if not opt.src or opt.src == "" then + error("xvm.files: src is required") + end + if not opt.dst or opt.dst == "" then + error("xvm.files: dst is required") + end + local owner = opt.name + or (_RUNTIME and _RUNTIME.pkg_name) + or "xvm" + -- Derived rather than caller-supplied so two declarations from one + -- package cannot collide on the same target name. + _XVM_FILES_SEQ = (_XVM_FILES_SEQ or 0) + 1 + local target = string.format("%s.files.%d", owner, _XVM_FILES_SEQ) + M.add(target, { + type = "files", + src = opt.src, + dst = opt.dst, + version = opt.version, + binding = opt.binding, + }) +end + function M.remove(name, version) local log = _get_log() if log then log.debug("xvm remove %s %s", name, version or "") end diff --git a/src/xpkg-executor.cppm b/src/xpkg-executor.cppm index 854b4d1..4d58841 100644 --- a/src/xpkg-executor.cppm +++ b/src/xpkg-executor.cppm @@ -52,6 +52,7 @@ struct HookResult { struct XvmOp { std::string op; // "add" | "remove" | "headers" | "remove_headers" + // | "subos_env" std::string name; std::string version; std::string bindir; @@ -87,6 +88,23 @@ struct XvmOp { // -- report a command line where a name belongs. std::vector args; + // op = "subos_env": one environment variable the subos exports to every + // process that enters it. + // + // Distinct from `envs` above, which is the environment a single program + // shim sets for itself when dispatched. That scope cannot serve a + // discovery protocol: the program that has to see LIBGL_DRIVERS_PATH is + // the user's own binary, which xlings never wraps. Same reason `src`/`dst` + // exist rather than another meaning piled onto `includedir` -- a new scope + // needs its own fields, or every reader has to know which op it is looking + // at before it can trust a value. + // + // `mode` carries the recipe's `op = "set"|"prepend"` argument, because + // `op` itself is already the category the consumer dispatches on. + std::string var; + std::string value; + std::string mode; + std::vector> envs; // environment variables }; @@ -529,6 +547,7 @@ bool load_stdlib(lua::State* L, std::string& err_out) { { "log", detail::log_lua }, { "pkginfo", detail::pkginfo_lua }, { "system", detail::system_lua }, + { "subos", detail::subos_lua }, { "xvm", detail::xvm_lua }, { "utils", detail::utils_lua }, { "pkgmanager", detail::pkgmanager_lua }, @@ -821,6 +840,9 @@ public: op.filename = read_field("filename"); op.binding = read_field("binding"); op.includedir = read_field("includedir"); + op.var = read_field("var"); + op.value = read_field("value"); + op.mode = read_field("mode"); op.src = read_field("src"); op.dst = read_field("dst"); diff --git a/src/xpkg-lua-stdlib.cppm b/src/xpkg-lua-stdlib.cppm deleted file mode 100644 index d0ef2fd..0000000 --- a/src/xpkg-lua-stdlib.cppm +++ /dev/null @@ -1,2494 +0,0 @@ -// Auto-generated by xmake before_build — do not edit manually -module; -export module mcpplibs.xpkg.lua_stdlib; -import std; - -export namespace mcpplibs::xpkg::detail { - -inline constexpr std::string_view prelude_lua = R"__LUA__( --- prelude.lua: xmake compatibility layer + import() for libxpkg runtime --- Loaded by PackageExecutor before any package script. - --- _LIBXPKG_MODULES is populated by C++ before this file runs -_LIBXPKG_MODULES = _LIBXPKG_MODULES or {} - --- Save Lua's built-in package.config before xpkg scripts overwrite `package` global -local _PATH_SEP = package.config:sub(1,1) - --- import(): maps "xim.libxpkg.X" to preloaded modules --- Also registers module as global variable (xmake compat: bare import() sets global) -function import(mod_path) - local name = mod_path:match("xim%.libxpkg%.(.+)") - if name and _LIBXPKG_MODULES[name] then - _G[name] = _LIBXPKG_MODULES[name] - return _LIBXPKG_MODULES[name] - end - -- pkgindex custom modules: import("xim.pkgindex.") - -- Loads /libs/.lua from the package index repository. - -- Modules are cached in _LIBXPKG_MODULES after first load. - local pkgindex_mod = mod_path:match("xim%.pkgindex%.(.+)") - if pkgindex_mod then - -- Check cache first - if _LIBXPKG_MODULES[pkgindex_mod] then - _G[pkgindex_mod] = _LIBXPKG_MODULES[pkgindex_mod] - return _LIBXPKG_MODULES[pkgindex_mod] - end - -- _PKGINDEX_DIR is set early (before L_dofile) so top-level imports work; - -- _RUNTIME.pkgindex_dir is set later by inject_context for hook calls. - local pkgindex_dir = _PKGINDEX_DIR - or (_RUNTIME and _RUNTIME.pkgindex_dir) - if pkgindex_dir and pkgindex_dir ~= "" then - local mod_file = pkgindex_dir .. "/libs/" .. pkgindex_mod .. ".lua" - local loader = loadfile(mod_file) - if loader then - local ok, mod = pcall(loader) - if ok and mod then - _LIBXPKG_MODULES[pkgindex_mod] = mod - _G[pkgindex_mod] = mod - return mod - end - end - end - end - - -- Stub for unknown imports (platform, base.runtime, etc.) - local log = _LIBXPKG_MODULES and _LIBXPKG_MODULES["log"] - if log then log.debug("unknown module '%s', returning stub", mod_path) end - local short = mod_path:match("[^.]+$") or mod_path - local function make_proxy() - return setmetatable({}, { - __index = function(_, k) return make_proxy() end, - __call = function() return make_proxy() end, - __tostring = function() return '' end, - __concat = function(a, b) return tostring(a) .. tostring(b) end, - }) - end - local stub = setmetatable({}, { - __index = function(_, k) return make_proxy() end, - }) - _G[short] = stub - return stub -end - --- os.* extensions (xmake compat) -os.isfile = function(p) - -- io.open succeeds on directories on Linux (fopen quirk), so also check it's not a dir - local f = io.open(p, "r") - if not f then return false end - f:close() - -- Reject directories: try reading 0 bytes; directories fail with "Is a directory" - local f2 = io.open(p, "rb") - if not f2 then return false end - local ok, _ = f2:read(0) - f2:close() - -- read(0) returns "" on regular files, nil on directories - return ok ~= nil -end -os.isdir = function(p) - local sep = _PATH_SEP - if sep == "\\" then - local ret = os.execute('if exist "' .. p .. '\\" exit 0') - return ret == 0 or ret == true - else - local ret = os.execute('[ -d "' .. p .. '" ]') - return ret == 0 or ret == true - end -end -os.host = function() - return _RUNTIME and _RUNTIME.platform or "linux" -end -os.trymv = function(src, dst) - -- If dst is an existing directory, move src INTO it (unix mv semantics) - if os.isdir(dst) then - local fname = src:match("[^/\\]+$") or src - dst = dst:gsub("[/\\]+$", "") .. "/" .. fname - end - local ok = pcall(os.rename, src, dst) - if ok then return true end - -- Cross-device or directory: fallback to shell mv - local ret = os.execute('mv "' .. src .. '" "' .. dst .. '" 2>/dev/null') - if ret == 0 or ret == true then return true end - -- Last resort: file copy + remove (files only) - local inf = io.open(src, "rb") - if not inf then return false end - local content = inf:read("*a"); inf:close() - local outf = io.open(dst, "wb") - if not outf then return false end - outf:write(content); outf:close() - local rm_ok = pcall(os.remove, src) - return rm_ok -end -os.mv = function(src, dst) return os.trymv(src, dst) end -os.cp = function(src, dst, opts) - -- opts accepted for xmake compat but ignored (cp -a already preserves symlinks) - -- Try shell cp first (handles directories, symlinks, etc.) - local sep = _PATH_SEP - if sep ~= "\\" then - local ret = os.execute('cp -a "' .. src .. '" "' .. dst .. '" 2>/dev/null') - if ret == 0 or ret == true then return true end - end - -- Fallback: file copy - local inf = io.open(src, "rb") - if not inf then return false end - local content = inf:read("*a"); inf:close() - local outf = io.open(dst, "wb") - if not outf then return false end - outf:write(content); outf:close() - return true -end --- POSIX: shell-escape a glob pattern, keeping glob meta-chars raw so the --- shell still expands them. Quoting the whole pattern (e.g. "/tmp/x/v*") --- suppresses globbing — `ls -d "/tmp/x/v*"` would only match a file --- literally named `v*`. Instead we backslash-escape characters that need --- shell quoting (whitespace, $, `, ', ", redirects, etc.) and leave glob --- meta (* ? [ ] ~) untouched. -local _SHELL_META_NO_GLOB = " \t\r\n$`'\"<>|&;()!\\" -local function _shell_glob_escape(s) - local out = {} - for i = 1, #s do - local c = s:sub(i, i) - if _SHELL_META_NO_GLOB:find(c, 1, true) then - out[#out+1] = "\\" - end - out[#out+1] = c - end - return table.concat(out) -end - -os.dirs = function(pattern) - local result = {} - local sep = _PATH_SEP - local cmd - if sep == "\\" then - -- cmd.exe `dir` does its own wildcard expansion on the pattern - -- argument, so quoting is safe and required for paths with spaces. - cmd = 'dir /B /AD "' .. pattern .. '" 2>nul' - else - -- POSIX `ls` does NOT expand globs itself; the shell does. Escape - -- shell metachars but leave glob meta raw so expansion still works. - cmd = 'ls -d ' .. _shell_glob_escape(pattern) .. ' 2>/dev/null' - end - local f = io.popen(cmd) - if f then - for line in f:lines() do - local clean = line:gsub("[\r\n]+$", "") -- strip CRLF - if clean ~= "" and os.isdir(clean) then - table.insert(result, clean) - end - end - f:close() - end - return result -end -os.sleep = function(ms) end -- stub -os.cd = function(dir) - if not dir then return false end - -- Fallback: pure Lua cannot chdir; C++ override replaces this after prelude - _CURRENT_DIR = dir - return true -end -os.iorun = function(cmd) - local f = io.popen(cmd .. " 2>/dev/null") - if not f then return "" end - local output = f:read("*a") - f:close() - return output or "" -end -os.setenv = function(k, v) - if _PATH_SEP == "\\" then - os.execute(string.format('setx %s "%s"', k, v)) - else - _ENV_OVERRIDES = _ENV_OVERRIDES or {} - _ENV_OVERRIDES[k] = v - end -end -os.addenv = function(k, v) - if _PATH_SEP == "\\" then - local cur = os.getenv(k) or "" - os.execute(string.format('setx %s "%s"', k, cur ~= "" and (cur .. ";" .. v) or v)) - else - _ENV_OVERRIDES = _ENV_OVERRIDES or {} - local cur = _ENV_OVERRIDES[k] or os.getenv(k) or "" - _ENV_OVERRIDES[k] = cur ~= "" and (cur .. ":" .. v) or v - end -end -os.exec = function(cmd) - if _ENV_OVERRIDES and _PATH_SEP ~= "\\" then - local prefix = "" - for k, v in pairs(_ENV_OVERRIDES) do - prefix = prefix .. string.format('%s="%s" ', k, v) - end - if prefix ~= "" then cmd = prefix .. cmd end - end - return os.execute(cmd) -end -os.tryrm = function(p) - if not p then return false end - local sep = _PATH_SEP - local cmd - if sep == "\\" then - cmd = 'rmdir /s /q "' .. p .. '" 2>nul' - else - cmd = 'rm -rf "' .. p .. '" 2>/dev/null' - end - os.execute(cmd) - return true -end -os.mkdir = function(p) - if not p then return false end - local sep = _PATH_SEP - local cmd - if sep == "\\" then - cmd = 'mkdir "' .. p .. '" 2>nul' - else - cmd = 'mkdir -p "' .. p .. '" 2>/dev/null' - end - os.execute(cmd) - return true -end - --- path module -path = {} -path.join = function(...) - local parts = {...} - local sep = "/" - local result = parts[1] or "" - for i = 2, #parts do - if parts[i] and parts[i] ~= "" then - result = result:gsub("[/\\]+$", "") .. sep .. parts[i] - end - end - return result -end -path.filename = function(p) - return (p or ""):match("[^/\\]+$") or "" -end -path.directory = function(p) - return (p or ""):match("^(.*)[/\\][^/\\]+$") or "" -end -path.is_absolute = function(p) - return (p or ""):sub(1,1) == "/" or (p or ""):match("^%a:[/\\]") ~= nil -end - --- io extensions -io.readfile = function(p) - local f = io.open(p, "r") - if not f then return nil end - local content = f:read("*a"); f:close() - return content -end -io.writefile = function(p, content) - local f = io.open(p, "w") - if not f then return false end - f:write(content); f:close() - return true -end - --- cprint: strip ${color} markers, fallback to print -cprint = function(fmt, ...) - if type(fmt) == "string" then - fmt = fmt:gsub("%${%w+}", "") - local ok, msg = pcall(string.format, fmt, ...) - print(ok and msg or fmt) - else - print(fmt) - end -end - --- string.split: split string by separator -if not string.split then - function string.split(s, sep, plain) - local result = {} - local i = 1 - while true do - local j, k = s:find(sep, i, plain) - if not j then - table.insert(result, s:sub(i)) - break - end - table.insert(result, s:sub(i, j-1)) - i = k + 1 - end - return result - end -end - --- string:trim(): remove leading/trailing whitespace -if not string.trim then - function string.trim(s) - return s:match("^%s*(.-)%s*$") or s - end -end - --- xmake compat globals -function is_host(name) - local host = _RUNTIME and _RUNTIME.platform or os.host() - return host == name -end -format = string.format -raise = function(msg) error(msg or "raise called", 2) end - --- string.replace: xmake compat (plain text replacement) -if not string.replace then - function string.replace(s, old, new, opts) - -- Plain text replacement (not pattern); opts accepted for xmake compat but ignored - local result = s - local i = 1 - while true do - local pos = result:find(old, i, true) - if not pos then break end - result = result:sub(1, pos - 1) .. new .. result:sub(pos + #old) - i = pos + #new - end - return result - end -end - --- try/catch: simulates xmake's try { function, catch { function } } syntax -function try(block) - local fn = block[1] - local catch_block = block.catch - local ok, result = pcall(fn) - if not ok then - if catch_block and catch_block[1] then - catch_block[1](result) - end - return nil - end - return result -end - -)__LUA__"; - -inline constexpr std::string_view log_lua = R"__LUA__( --- xim.libxpkg.log: logging API for xpkg scripts -local M = {} - -local PREFIX = "[xim:xpkg]: " - --- Log levels: 0=debug, 1=info, 2=warn, 3=error, 4=silent -local LEVEL_DEBUG = 0 -local LEVEL_INFO = 1 -local LEVEL_WARN = 2 -local LEVEL_ERROR = 3 - -local _level = LEVEL_INFO -- default: show info and above - -local function _log(text, ...) - if not text then return end - local ok, msg = pcall(string.format, text, ...) - msg = ok and msg or tostring(text) - msg = msg:gsub("%${%w+}", "") - io.write(PREFIX .. msg .. "\n") - io.flush() -end - -function M.debug(text, ...) - if _level <= LEVEL_DEBUG then _log(text, ...) end -end - -function M.info(text, ...) - if _level <= LEVEL_INFO then _log(text, ...) end -end - -function M.warn(text, ...) - if _level <= LEVEL_WARN then _log("[WARN] " .. (text or ""), ...) end -end - -function M.error(text, ...) - if _level <= LEVEL_ERROR then _log("[ERROR] " .. (text or ""), ...) end -end - --- Set log level: "debug", "info", "warn", "error", "silent" -function M.set_level(level) - if level == "debug" or level == 0 then _level = LEVEL_DEBUG - elseif level == "info" or level == 1 then _level = LEVEL_INFO - elseif level == "warn" or level == 2 then _level = LEVEL_WARN - elseif level == "error" or level == 3 then _level = LEVEL_ERROR - elseif level == "silent" or level == 4 then _level = 4 - end -end - -function M.get_level() - return _level -end - -return M - -)__LUA__"; - -inline constexpr std::string_view pkginfo_lua = R"__LUA__( --- xim.libxpkg.pkginfo: package info API reading from _RUNTIME global -local M = {} - -local function _get_log() - return _LIBXPKG_MODULES and _LIBXPKG_MODULES["log"] -end - -function M.name() return _RUNTIME and _RUNTIME.pkg_name or nil end -function M.version() return _RUNTIME and _RUNTIME.version or nil end -function M.install_file() return _RUNTIME and _RUNTIME.install_file or nil end -function M.deps_list() return (_RUNTIME and _RUNTIME.deps_list) or {} end - -local function _ends_with(s, suffix) - return suffix == "" or s:sub(-#suffix) == suffix -end - -local function _parse_namespace(name) - local ns, bare = name:match("^([^:]+):(.+)$") - if ns then return ns, bare end - return nil, name -end - -local function _match_store_name(dirname, ns, bare) - if ns then - -- namespace specified: exact match "ns-x-bare" - return dirname == ns .. "-x-" .. bare - else - -- no namespace: match "bare" or "*-x-bare" - return dirname == bare or _ends_with(dirname, "-x-" .. bare) - end -end - -local function _scan_dir(base, ns, bare, dep_version) - if not base or not os.isdir(base) then return nil end - local dirs = os.dirs(path.join(base, "*")) or {} - for _, dep_root in ipairs(dirs) do - local dirname = path.filename(dep_root) - if _match_store_name(dirname, ns, bare) then - local ver = dep_version - if not ver then - local vers = os.dirs(path.join(dep_root, "*")) or {} - table.sort(vers) - if #vers > 0 then ver = path.filename(vers[#vers]) end - end - if ver then - local install_dir = path.join(dep_root, ver) - if os.isdir(install_dir) then return install_dir end - end - end - end - return nil -end - -local function _resolve_dep_via_scan(dep_name, dep_version) - local log = _get_log() - local ns, bare = _parse_namespace(dep_name) - if log then log.debug("scan dep=%s ns=%s bare=%s ver=%s", - dep_name, tostring(ns), bare, tostring(dep_version)) end - -- 1. Search xpkg_dir (lua package files directory) - local xpkg_dir = _RUNTIME and _RUNTIME.xpkg_dir - if log then log.debug("step1 xpkg_dir=%s", tostring(xpkg_dir)) end - local result = _scan_dir(xpkg_dir, ns, bare, dep_version) - if result then if log then log.debug("found via step1") end; return result end - -- 2. Search xpkgs install root (install_dir's grandparent) - if _RUNTIME and _RUNTIME.install_dir then - local xpkgs_root = path.directory(path.directory(_RUNTIME.install_dir)) - if log then log.debug("step2 xpkgs_root=%s", tostring(xpkgs_root)) end - result = _scan_dir(xpkgs_root, ns, bare, dep_version) - if result then if log then log.debug("found via step2") end; return result end - end - -- 3. Search project xpkgs (handles global-pkg depending on project-local pkg) - local proj_data = _RUNTIME and _RUNTIME.project_data_dir - if log then log.debug("step3 project_data_dir=%s", tostring(proj_data)) end - if proj_data and proj_data ~= "" then - local proj_xpkgs = path.join(proj_data, "xpkgs") - if log then log.debug("step3 proj_xpkgs=%s exists=%s", - proj_xpkgs, tostring(os.isdir(proj_xpkgs))) end - result = _scan_dir(proj_xpkgs, ns, bare, dep_version) - if result then if log then log.debug("found via step3") end; return result end - end - if log then log.debug("scan: not found") end - return nil -end - --- Try xvm registry: for "ns:name", try "ns-name" first, then bare "name" -local function _resolve_dep_via_xvm(dep_name, dep_version) - local log = _get_log() - local ok_xvm, xvm_mod = pcall(require, "xim.libxpkg.xvm") - if not ok_xvm or not xvm_mod then - xvm_mod = _LIBXPKG_MODULES and _LIBXPKG_MODULES["xvm"] - end - if not xvm_mod then - if log then log.debug("xvm: module not available") end - return nil - end - local ns, bare = _parse_namespace(dep_name) - local candidates = ns and {ns .. "-" .. bare, bare} or {bare} - if log then log.debug("xvm candidates: %s", table.concat(candidates, ", ")) end - for _, xvm_name in ipairs(candidates) do - local info = xvm_mod.info(xvm_name, dep_version) - if log then log.debug("xvm.info(%s) = %s", - xvm_name, info and ("SPath=" .. tostring(info["SPath"])) or "nil") end - if info and info["SPath"] and info["SPath"] ~= "" then - local spath = info["SPath"] - local pver = (info["Version"] or dep_version or ""):gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1") - if pver ~= "" then - local head = spath:match("^(.*)" .. pver) - if head then - return path.join(head:gsub("[/\\]+$", ""), info["Version"] or dep_version) - end - end - end - end - if log then log.debug("xvm: not found") end - return nil -end - -function M.dep_install_dir(dep_name, dep_version) - local result = _resolve_dep_via_scan(dep_name, dep_version) - if result then return result end - return _resolve_dep_via_xvm(dep_name, dep_version) -end - -function M.install_dir(pkgname, pkgversion) - if not pkgname then - return _RUNTIME and _RUNTIME.install_dir or nil - end - local dir = M.dep_install_dir(pkgname, pkgversion) - if dir then return dir end - local log = _get_log() - if log then log.error("cannot get install dir for %s@%s", - tostring(pkgname), tostring(pkgversion or "latest")) end - return nil -end - --- ───────────────────────────────────────────────────────────────────── --- build_dep API --- ───────────────────────────────────────────────────────────────────── --- Returns metadata about a build-time dep available to the current --- install hook. Build deps are payloads xlings ensured are present in --- the xpkgs store but did NOT activate in subos workspace. Use this --- API instead of relying on PATH / shims when the consumer needs an --- ABSOLUTE PATH or wants explicit version selection independent of --- the user's active workspace. --- --- local gcc = pkginfo.build_dep("gcc") --- -- gcc.path : install_dir of the chosen build dep version --- -- gcc.bin : /bin --- -- gcc.version : resolved version string --- --- Resolution order: --- 1. Env var XLINGS_BUILDDEP__PATH (injected by the --- xlings installer when the consumer's `build` deps were resolved --- to a concrete version). --- 2. Fallback: scan xpkgs the same way `dep_install_dir` does. --- Returns highest available version when version is omitted. --- --- Returns nil if the build dep is not available. -function M.build_dep(dep_name, dep_version) - local log = _get_log() - if not dep_name or dep_name == "" then return nil end - - local function _upper(s) return (s:gsub("[^%w]", "_")):upper() end - local env_key = "XLINGS_BUILDDEP_" .. _upper(dep_name) .. "_PATH" - local env_path = os.getenv(env_key) - local install_dir - if env_path and env_path ~= "" and os.isdir(env_path) then - install_dir = env_path - if log then log.debug("build_dep %s -> %s (via %s)", - dep_name, env_path, env_key) end - else - install_dir = M.dep_install_dir(dep_name, dep_version) - if log then log.debug("build_dep %s -> %s (via scan)", - dep_name, tostring(install_dir)) end - end - - if not install_dir then return nil end - - local resolved_ver = dep_version - if not resolved_ver or resolved_ver == "" then - -- The install_dir's leaf is the version (xpkgs//). - resolved_ver = path.filename(install_dir) - end - - return { - path = install_dir, - bin = path.join(install_dir, "bin"), - include = path.join(install_dir, "include"), - lib = path.join(install_dir, "lib"), - version = resolved_ver, - } -end - --- Convenience: prepend every build dep's `bin/` to PATH for the --- duration of the callback, then restore. Lets install hooks call --- bare `gcc` / `patchelf` etc and pick up the build-dep version --- without the hook needing to splice paths manually. The xlings --- installer also pre-injects PATH globally for the hook subprocess, --- so most hooks won't need this — useful only when an install hook --- spawns sub-processes that need a different PATH. -function M.with_build_deps_on_path(build_dep_names, fn) - local log = _get_log() - local original_path = os.getenv("PATH") or "" - local extra = {} - for _, n in ipairs(build_dep_names or {}) do - local d = M.build_dep(n) - if d and d.bin and os.isdir(d.bin) then - table.insert(extra, d.bin) - elseif log then - log.warn("with_build_deps_on_path: %s not available", n) - end - end - if #extra == 0 then return fn() end - local new_path = table.concat(extra, path.envsep()) .. path.envsep() .. original_path - os.setenv("PATH", new_path) - local ok, err = pcall(fn) - os.setenv("PATH", original_path) - if not ok then error(err) end -end - -return M - -)__LUA__"; - -inline constexpr std::string_view system_lua = R"__LUA__( --- xim.libxpkg.system: system operations API -local M = {} - -function M.exec(cmd, opt) - opt = opt or {} - local retries = opt.retry or 0 - local attempts = retries + 1 - for i = 1, attempts do - local ret = os.execute(cmd) - if ret == 0 or ret == true then return end - if i == attempts then - error("exec failed after " .. attempts .. " attempt(s): " .. tostring(cmd)) - end - end -end - -function M.rundir() return _RUNTIME and _RUNTIME.run_dir or nil end -function M.xpkgdir() return _RUNTIME and _RUNTIME.xpkg_dir or nil end -function M.bindir() return _RUNTIME and _RUNTIME.bin_dir or nil end -function M.xpkg_args() return (_RUNTIME and _RUNTIME.args) or {} end -function M.subos_sysrootdir() return _RUNTIME and _RUNTIME.subos_sysrootdir or nil end - -function M.run_in_script(content, admin) - local tmpfile = os.tmpname() - -- write content to temp file - if not io.writefile(tmpfile, content) then - error("run_in_script: failed to write temp script") - end - local ok, err = pcall(function() - os.execute("chmod +x " .. tmpfile) - local prefix = (admin == true) and "sudo " or "" - local ret = os.execute(prefix .. tmpfile) - if ret ~= 0 and ret ~= true then - error("script failed with code: " .. tostring(ret)) - end - end) - os.remove(tmpfile) -- always cleanup - if not ok then error(err) end -end - -function M.unix_api() - return { - append_to_shell_profile = function(config) - if not config then return end - if type(config) == "string" then - config = { posix = config, fish = config } - end - local profile_dir = _RUNTIME.run_dir or "/tmp" - local posix = path.join(profile_dir, "xlings-profile.sh") - local fish = path.join(profile_dir, "xlings-profile.fish") - if config.posix and os.isfile(posix) then - local cur = io.readfile(posix) or "" - if not cur:find(config.posix, 1, true) then - io.writefile(posix, cur .. "\n" .. config.posix) - end - end - if config.fish and os.isfile(fish) then - local cur = io.readfile(fish) or "" - if not cur:find(config.fish, 1, true) then - io.writefile(fish, cur .. "\n" .. config.fish) - end - end - end - } -end - -return M - -)__LUA__"; - -inline constexpr std::string_view xvm_lua = R"__LUA__( --- xim.libxpkg.xvm: version management integration (collects ops for C++ processing) -local M = {} - -local function _get_log() - return _LIBXPKG_MODULES and _LIBXPKG_MODULES["log"] -end - -_XVM_OPS = _XVM_OPS or {} - ---- Register one entry with the version manager. --- @param name target name --- @param opt table with: --- type "program" (default) | "lib" | "files" --- version defaults to the package version --- bindir directory holding the artifact (default: install_dir) --- filename artifact name inside bindir --- alias name it is exposed under --- binding "@" -- which release this belongs to --- args list of arguments injected ahead of the user's own when the --- shim dispatches. Use this rather than appending to `alias`: --- consumers split the alias on its first space, so a path with --- one in it breaks, and every reader of `alias` then shows a --- command line where a name belongs. --- envs environment variables --- --- For type = "files", the entry describes an asset placed into the subos --- instead of an artifact to dispatch: --- src source, relative to the payload root --- dst destination, relative to the subos root --- --- Both must be relative. A payload is shared between subos and --- reference-counted, so an absolute destination recorded against it would --- be correct for exactly one subos and wrong for the rest. -function M.add(name, opt) - opt = opt or {} - local entry = { - op = "add", - name = name, - version = opt.version or (_RUNTIME and _RUNTIME.version) or "", - bindir = opt.bindir or (_RUNTIME and _RUNTIME.install_dir) or "", - alias = opt.alias or "", - type = opt.type or "", - filename = opt.filename or "", - binding = opt.binding or "", - src = opt.src or "", - dst = opt.dst or "", - args = opt.args or nil, - envs = opt.envs or nil, - } - local log = _get_log() - if log then log.debug("xvm add %s version=%s", name, entry.version) end - table.insert(_XVM_OPS, entry) -end - ---- Declare an asset this package places into the subos. --- --- Sugar over `M.add` for the case where the entry is a file rather than --- something to dispatch, so the caller does not have to invent a target --- name: one is derived from the package name. A release may declare several, --- and each call adds one. --- --- @param opt src / dst (both relative, see M.add), plus binding -function M.files(opt) - opt = opt or {} - if not opt.src or opt.src == "" then - error("xvm.files: src is required") - end - if not opt.dst or opt.dst == "" then - error("xvm.files: dst is required") - end - local owner = opt.name - or (_RUNTIME and _RUNTIME.pkg_name) - or "xvm" - -- Derived rather than caller-supplied so two declarations from one - -- package cannot collide on the same target name. - _XVM_FILES_SEQ = (_XVM_FILES_SEQ or 0) + 1 - local target = string.format("%s.files.%d", owner, _XVM_FILES_SEQ) - M.add(target, { - type = "files", - src = opt.src, - dst = opt.dst, - version = opt.version, - binding = opt.binding, - }) -end - -function M.remove(name, version) - local log = _get_log() - if log then log.debug("xvm remove %s %s", name, version or "") end - table.insert(_XVM_OPS, {op = "remove", name = name, version = version or ""}) -end - -function M.use(name, version) - -- stub: version switching handled by C++ side -end - --- Load VersionDB from config files (global + project) -local _versions_cache = nil -local function _load_versions() - if _versions_cache then return _versions_cache end - local ok_json, json_mod = pcall(require, "xim.libxpkg.json") - if not ok_json then - json_mod = _LIBXPKG_MODULES and _LIBXPKG_MODULES["json"] - end - if not json_mod then return nil end - - local function load_file(config_path) - local f = io.open(config_path, "r") - if not f then return nil end - local content = f:read("*a"); f:close() - if not content or content == "" then return nil end - local ok, data = pcall(json_mod.decode, content) - if not ok or type(data) ~= "table" then return nil end - return data.versions or nil - end - - local merged = {} - -- 1. Load global versions: prefer XLINGS_HOME, fallback to ~/.xlings - local xlings_home = os.getenv("XLINGS_HOME") - if not xlings_home or xlings_home == "" then - local home = os.getenv("HOME") or os.getenv("USERPROFILE") or "" - xlings_home = home .. "/.xlings" - end - local global_versions = load_file(xlings_home .. "/.xlings.json") - if global_versions then - for k, v in pairs(global_versions) do merged[k] = v end - end - -- 2. Load project versions (project_data_dir is 2 levels below project root) - if _RUNTIME and _RUNTIME.project_data_dir and _RUNTIME.project_data_dir ~= "" then - local project_dir = path.directory(path.directory(_RUNTIME.project_data_dir)) - local project_versions = load_file(path.join(project_dir, ".xlings.json")) - if project_versions then - for k, v in pairs(project_versions) do merged[k] = v end - end - end - _versions_cache = merged - return _versions_cache -end - -function M.has(name, version) - -- Check pending ops first (current session adds) - for _, entry in ipairs(_XVM_OPS) do - if entry.op == "add" and entry.name == name then return true end - end - -- Check persisted VersionDB - local versions = _load_versions() - if not versions then return false end - local vinfo = versions[name] - if not vinfo then return false end - if not version or version == "" then return true end - if vinfo.versions then - for ver_key, _ in pairs(vinfo.versions) do - if ver_key == version then return true end - end - end - return false -end - -function M.info(name, version) - local versions = _load_versions() - if not versions then return nil end - local vinfo = versions[name] - if not vinfo or not vinfo.versions then return nil end - - -- Find matching version - local vdata = nil - local matched_version = version or "" - if version and version ~= "" then - vdata = vinfo.versions[version] - end - if not vdata then - -- Try first available version - for ver_key, ver_val in pairs(vinfo.versions) do - vdata = ver_val - matched_version = ver_key - break - end - end - if not vdata then return nil end - - local info_table = { - Name = name, - Version = matched_version, - Type = vinfo.type or "program", - Program = vinfo.filename or name, - SPath = vdata.path or "", - TPath = vdata.path or "", - Alias = vdata.alias or nil, - Envs = vdata.envs or nil, - } - return info_table -end - --- Deprecated: use log.set_level() instead. Kept for backward compat. -function M.log_tag(enable) - return enable -end - ---- Unified registration of programs, libraries, and headers --- @param name package name (root node in binding tree) --- @param opt table with: --- install_dir install root directory (default _RUNTIME.install_dir) --- version version string (default _RUNTIME.version) --- bindir programs directory (relative or absolute, default "bin") --- libdir library directory (relative or absolute, optional) --- includedir header directory (relative or absolute, optional) --- programs list of program names (optional) --- libs list of library filenames (optional) -function M.setup(name, opt) - opt = opt or {} - local install_dir = opt.install_dir or (_RUNTIME and _RUNTIME.install_dir) or "" - local version = opt.version or (_RUNTIME and _RUNTIME.version) or "" - local binding = name .. "@" .. version - - local function resolve(dir) - if not dir then return nil end - if path.is_absolute(dir) then return dir end - return path.join(install_dir, dir) - end - - -- 1. Register root node - M.add(name) - - -- 2. Batch register programs - if opt.programs then - local bindir = resolve(opt.bindir or "bin") - for _, prog in ipairs(opt.programs) do - M.add(prog, { bindir = bindir, binding = binding }) - end - end - - -- 3. Batch register libraries - if opt.libs then - local libdir = resolve(opt.libdir or "lib") - for _, lib in ipairs(opt.libs) do - M.add(lib, { - type = "lib", bindir = libdir, - alias = lib, filename = lib, - binding = binding, - }) - end - end - - -- 4. Header directory -> C++ side creates symlinks to sysroot - if opt.includedir then - local includedir = resolve(opt.includedir) - table.insert(_XVM_OPS, { op = "headers", includedir = includedir }) - end -end - ---- Unified unregistration -function M.teardown(name, opt) - opt = opt or {} - M.remove(name) - if opt.programs then - for _, prog in ipairs(opt.programs) do M.remove(prog) end - end - if opt.libs then - for _, lib in ipairs(opt.libs) do M.remove(lib) end - end - if opt.includedir then - local install_dir = opt.install_dir or (_RUNTIME and _RUNTIME.install_dir) or "" - local includedir = opt.includedir - if not path.is_absolute(includedir) then - includedir = path.join(install_dir, includedir) - end - table.insert(_XVM_OPS, { op = "remove_headers", includedir = includedir }) - end -end - -return M - -)__LUA__"; - -inline constexpr std::string_view utils_lua = R"__LUA__( --- xim.libxpkg.utils: utility functions -local M = {} - -local function _get_log() - return _LIBXPKG_MODULES and _LIBXPKG_MODULES["log"] -end - -function M.filepath_to_absolute(filepath) - if path.is_absolute(filepath) then return filepath end - return path.join(os.getenv("PWD") or ".", filepath) -end - -function M.try_download_and_check(url, dir, sha256) - local log = _get_log() - local filename = url:match("[^/]+$") or "download" - local dest = path.join(dir, filename) - local ret = os.execute(string.format('curl -fsSL -o "%s" "%s"', dest, url)) - if ret ~= 0 and ret ~= true then - if log then log.error("download failed: %s", url) end - return false - end - if sha256 then - local f = io.popen("sha256sum " .. dest) - local out = f and f:read("*l") or "" - if f then f:close() end - local actual = out:match("^(%x+)") - if actual ~= sha256 then - if log then log.error("sha256 mismatch for %s", dest) end - return false - end - end - return true -end - -function M.input_args_process(cmds_kv, args) - local result = {} - local i = 1 - local arglist = args or {} - while i <= #arglist do - local arg = arglist[i] - -- --key=value format - local k, v = arg:match("^(%-%-[%w%-]+)=(.+)$") - if k and cmds_kv[k] ~= nil then - result[k] = v - i = i + 1 - elseif arg:match("^%-%-") and cmds_kv[arg] ~= nil then - -- --key value format - if i < #arglist then - result[arg] = arglist[i + 1] - i = i + 2 - else - result[arg] = true - i = i + 1 - end - else - i = i + 1 - end - end - return true, result -end - -return M - -)__LUA__"; - -inline constexpr std::string_view pkgmanager_lua = R"__LUA__( -local M = {} - -local function _get_log() - return _LIBXPKG_MODULES and _LIBXPKG_MODULES["log"] -end - -function M.install(target) - if not target or target == "" then return end - local log = _get_log() - if log then log.debug("pkgmanager.install(%s)", tostring(target)) end - if not _INSTALL_REQUESTS then _INSTALL_REQUESTS = {} end - table.insert(_INSTALL_REQUESTS, {op = "install", target = tostring(target)}) -end - -function M.remove(target) - if not target or target == "" then return end - local log = _get_log() - if log then log.debug("pkgmanager.remove(%s)", tostring(target)) end - if not _INSTALL_REQUESTS then _INSTALL_REQUESTS = {} end - table.insert(_INSTALL_REQUESTS, {op = "remove", target = tostring(target)}) -end - -function M.uninstall(target) - M.remove(target) -end - -return M - -)__LUA__"; - -inline constexpr std::string_view elfpatch_lua_0 = R"__LUA__( --- xim.libxpkg.elfpatch: ELF and Mach-O patch helpers -local M = {} - -local _tool_cache = {} - -local function _trim(s) - if not s then return s end - return s:match("^%s*(.-)%s*$") -end - -local function _shell_quote(s) - s = tostring(s or "") - return "'" .. s:gsub("'", "'\\''") .. "'" -end - -local function _get_log() - return _LIBXPKG_MODULES and _LIBXPKG_MODULES["log"] -end - -local function _warn(msg) - local log = _get_log() - if log then log.warn("elfpatch: %s", msg) - else io.write("[xim:xpkg]: WARNING: " .. msg .. "\n") end -end - -local function _info(msg) - local log = _get_log() - if log then log.debug("elfpatch: %s", msg) - else io.write("[xim:xpkg]: elfpatch: " .. msg .. "\n") end -end - -local function _null_redirect() - if _RUNTIME and _RUNTIME.platform == "windows" then return " >NUL 2>&1" end - return " >/dev/null 2>&1" -end - -local function _err_redirect() - if _RUNTIME and _RUNTIME.platform == "windows" then return " 2>NUL" end - return " 2>/dev/null" -end - -local function _exec_ok(cmd) - local ok, _, code = os.execute(cmd .. _null_redirect()) - if ok == true or ok == 0 then return true end - _info("exec failed (code=" .. tostring(code) .. "): " .. cmd) - return false -end - -local function _iorun(cmd) - local f = io.popen(cmd .. _err_redirect()) - if not f then return nil end - local output = f:read("*a") - f:close() - return output -end - --- Find a tool by searching fixed paths then system PATH. --- Search order: subos/bin → _RUNTIME.bin_dir → system PATH (/usr/bin etc.) --- Returns { program = "/abs/path/to/tool" } or nil. -local function _find_tool(toolname) - if _tool_cache[toolname] ~= nil then - if _tool_cache[toolname] == false then return nil end - return _tool_cache[toolname] - end - - local candidates = {} - - -- 1. subos bin (patchelf, readelf live here) - local sysroot = _RUNTIME and _RUNTIME.subos_sysrootdir - if sysroot and sysroot ~= "" then - candidates[#candidates + 1] = path.join(sysroot, "bin", toolname) - end - - -- 2. _RUNTIME.bin_dir (~/.xlings/bin) - local bin_dir = _RUNTIME and _RUNTIME.bin_dir - if bin_dir then - candidates[#candidates + 1] = path.join(bin_dir, toolname) - end - - -- 3. macOS system tools - if is_host("macosx") then - candidates[#candidates + 1] = "/usr/bin/" .. toolname - end - - -- 4. common system paths - candidates[#candidates + 1] = "/usr/bin/" .. toolname - candidates[#candidates + 1] = "/usr/local/bin/" .. toolname - - for _, p in ipairs(candidates) do - if os.isfile(p) then - local tool = { program = p } - _info("using " .. toolname .. ": " .. p) - _tool_cache[toolname] = tool - return tool - end - end - - -- 5. Last resort: search system PATH via shell - local which_cmd = is_host("windows") and "where" or "which" - local resolved = _trim(_iorun(which_cmd .. " " .. _shell_quote(toolname))) - if resolved and resolved ~= "" and os.isfile(resolved) then - local tool = { program = resolved } - _info("using " .. toolname .. ": " .. resolved .. " (PATH)") - _tool_cache[toolname] = tool - return tool - end - - _warn(toolname .. " not found") - _tool_cache[toolname] = false - return nil -end - -local function _read_magic(filepath, size) - local f = io.open(filepath, "rb") - if not f then return nil end - local magic = f:read(size) - f:close() - return magic -end - -local function _is_elf(filepath) - return _read_magic(filepath, 4) == "\x7fELF" -end - --- Read ELF e_machine (offset 18, 2 bytes little-endian for ELFCLASS64 --- on x86_64; ELF header layout is identical across the two classes for --- the e_machine field). Returns nil for non-ELF files. -local _EM_X86_64 = 62 -- 0x3e -local _EM_AARCH64 = 183 -- 0xb7 -local _EM_386 = 3 -local _EM_ARM = 40 - -local function _read_e_machine(filepath) - local f = io.open(filepath, "rb") - if not f then return nil end - local hdr = f:read(20) - f:close() - if not hdr or #hdr < 20 then return nil end - if hdr:sub(1, 4) ~= "\x7fELF" then return nil end - local lo = hdr:byte(19) or 0 - local hi = hdr:byte(20) or 0 - return lo + hi * 256 -end - --- Best-effort host-arch detection. Default x86_64 because that's where --- xlings's binary distributions live; aarch64 is the second most common. --- Mismatch (e.g. an x86_64 host with an aarch64 ELF in install_dir) means --- the binary is for a different target and must NOT be patched — patchelf --- on it would corrupt or no-op spectacularly. _is_elf_for_host returns --- true only when the file is ELF AND its e_machine matches the host. -local function _host_e_machine() - local arch = (os.arch and os.arch()) or "x86_64" - if arch:find("aarch64") or arch:find("arm64") then return _EM_AARCH64 end - if arch:find("x86_64") or arch == "x64" then return _EM_X86_64 end - if arch:find("i386") or arch == "x86" then return _EM_386 end - if arch:find("arm") then return _EM_ARM end - return _EM_X86_64 -end - -local function _is_elf_for_host(filepath) - if not _is_elf(filepath) then return false end - local em = _read_e_machine(filepath) - if not em then return false end - return em == _host_e_machine() -end - --- Read PT_INTERP existence. Used by the fallback scan / declared-bins --- paths to discriminate executables from shared libraries WITHOUT relying --- on filename heuristics (`.so`): --- has INTERP → executable / PIE binary → set INTERP + RPATH --- no INTERP → shared library / static binary → RPATH only (set INTERP --- would fail with patchelf exit code 1 and emit log noise) --- Probe is `patchelf --print-interpreter`: empty output means no INTERP --- segment, non-empty means present. If patchelf isn't found yet --- (early-bootstrap), assume INTERP present so we don't silently skip --- legitimate executables; the actual --set-interpreter call would fail --- harmlessly later anyway. -local function _has_pt_interp(filepath, patch_tool) - if not patch_tool then return true end - local cmd = _shell_quote(patch_tool.program) - .. " --print-interpreter " .. _shell_quote(filepath) .. _err_redirect() - local h = io.popen(cmd, "r") - if not h then return true end - local out = h:read("*a") or "" - h:close() - return out:gsub("%s+", "") ~= "" -end - -local function _is_macho(filepath) - local magic = _read_magic(filepath, 4) - if magic == "\xfe\xed\xfa\xce" - or magic == "\xfe\xed\xfa\xcf" - or magic == "\xce\xfa\xed\xfe" - or magic == "\xcf\xfa\xed\xfe" - or magic == "\xca\xfe\xba\xbe" - or magic == "\xbe\xba\xfe\xca" then - return true - end - - local otool = _find_tool("otool") - if not otool then - return false - end - return _exec_ok(_shell_quote(otool.program) .. " -h " .. _shell_quote(filepath)) -end - -local function _collect_targets(target, opts) - if not target then return {} end - if os.isfile(target) then return { target } end - if not os.isdir(target) then return {} end - - opts = opts or {} - local recurse = opts.recurse - if recurse == nil then recurse = true end - local include_shared_libs = opts.include_shared_libs - if include_shared_libs == nil then include_shared_libs = true end - - local matcher = _is_elf - if is_host("macosx") then - matcher = _is_macho - end - - local find_cmd - if recurse then - find_cmd = "find " .. _shell_quote(target) .. " -type f" - else - find_cmd = "find " .. _shell_quote(target) .. " -maxdepth 1 -type f" - end - - local binaries = {} - local f = io.popen(find_cmd .. " 2>/dev/null") - if f then - for line in f:lines() do - local filepath = _trim(line) - if filepath and filepath ~= "" then - if not include_shared_libs then - local is_shared = filepath:find("%.so", 1, true) ~= nil - or filepath:find("%.dylib", 1, true) ~= nil - if is_shared then - goto continue - end - end - if matcher(filepath) then - table.insert(binaries, filepath) - end - ::continue:: - end - end - f:close() - end - return binaries -end - -local function _normalize_rpath_list(rpath) - if not rpath then return nil end - if type(rpath) == "string" then - local values, seen = {}, {} - for p in rpath:gmatch("[^:]+") do - p = _trim(p) - if p and p ~= "" and not seen[p] then - seen[p] = true - table.insert(values, p) - end - end - return #values > 0 and values or nil - end - if type(rpath) ~= "table" then return nil end - - local seen, values = {}, {} - for _, p in ipairs(rpath) do - if p and p ~= "" and not seen[p] then - seen[p] = true - table.insert(values, p) - end - end - return #values > 0 and values or nil -end - -local function _normalize_rpath(rpath) - local values = _normalize_rpath_list(rpath) - if not values then return nil end - return table.concat(values, ":") -end - -local function _detect_system_loader() - local candidates = { - "/lib64/ld-linux-x86-64.so.2", - "/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2", - "/lib/ld-musl-x86_64.so.1", - } - for _, p in ipairs(candidates) do - if os.isfile(p) then return p end - end - - local readelf = _find_tool("readelf") - if readelf and os.isfile("/bin/sh") then - local output = _iorun(_shell_quote(readelf.program) .. " -l /bin/sh") - if output then - local loader = _trim(output:match("Requesting program interpreter:%s*([^%]]+)")) - if loader and os.isfile(loader) then - return loader - end - end - end - return nil -end - -local function _resolve_loader(loader_opt) - if not loader_opt then return nil end - if loader_opt == "system" then return _detect_system_loader() end - if loader_opt == "subos" then - local sysroot = _RUNTIME and _RUNTIME.subos_sysrootdir - if sysroot and sysroot ~= "" then - for _, p in ipairs({ - path.join(sysroot, "lib", "ld-linux-x86-64.so.2"), - path.join(sysroot, "lib64", "ld-linux-x86-64.so.2"), - path.join(sysroot, "lib", "ld-musl-x86_64.so.1"), - }) do - if os.isfile(p) then - return p - end - end - end - return nil - end - return loader_opt -end - -local function _fix_macho_dylib_refs(tool, filepath, opts) - local otool = _find_tool("otool") - if not otool then - return true - end - - local output = _iorun(_shell_quote(otool.program) .. " -L " .. _shell_quote(filepath)) - if not output or output == "" then - return true - end - - for line in output:gmatch("[^\n]+") do - local dep = _trim(line:match("^%s*(.-)%s+%(")) - if dep and dep ~= "" - and not dep:match("^@") - and not dep:match("^/usr/lib/") - and not dep:match("^/System/") then - local basename = path.filename(dep) - local new_ref = "@rpath/" .. basename - local cmd = _shell_quote(tool.program) - .. " -change " - .. _shell_quote(dep) .. " " - .. _shell_quote(new_ref) .. " " - .. _shell_quote(filepath) - if not _exec_ok(cmd) then - local msg = "failed to change " .. dep .. " for " .. filepath - if opts.strict then - error(msg) - end - _warn(msg) - return false - end - end - end - return true -end - --- Shared shrink helper -local function _apply_shrink(patch_tool, filepath, shrink, result) - if shrink == true then - if _exec_ok(_shell_quote(patch_tool.program) .. " --shrink-rpath " .. _shell_quote(filepath)) then - result.shrinked = result.shrinked + 1 - else - result.shrink_failed = result.shrink_failed + 1 - end - end -end - --- Patch directories as executables (interpreter + rpath). Files without --- PT_INTERP (shared libs that happened to land in a bin dir, static --- binaries) get rpath-only treatment instead of failing the whole entry. --- --- Op order: --set-rpath FIRST, then --set-interpreter. patchelf 0.18.0 --- has an order-sensitive corruption bug on compact ELFs (≤ ~280 KB, --- e.g. ninja 1.12.1 at 273 KB): when --set-interpreter runs first, it --- extends PT_LOAD and shifts the dynamic section; the subsequent --- --set-rpath operates on stale offsets and writes through DT_NEEDED, --- corrupting the binary irreversibly (loader segfaults at execve+1). --- Note this is patchelf's INTERNAL processing order — passing both --- flags in a single command also fails because patchelf processes --- interp before rpath internally regardless of CLI order. The --- workaround is two separate invocations in reverse order. --- See docs/plans/2026-05-03-patchelf-order-bug-analysis.md. -local function _patch_elf_executables(patch_tool, dirs, install_dir, loader, rpath, shrink, result) - for _, dir in ipairs(dirs) do - local full = path.is_absolute(dir) and dir or path.join(install_dir, dir) - local targets = _collect_targets(full, { include_shared_libs = true }) - for _, filepath in ipairs(targets) do - result.scanned = result.scanned + 1 - local ok = true - if rpath and rpath ~= "" then - ok = _exec_ok(_shell_quote(patch_tool.program) - .. " --set-rpath " .. _shell_quote(rpath) - .. " " .. _shell_quote(filepath)) - end - if ok and loader and _has_pt_interp(filepath, patch_tool) then - ok = _exec_ok(_shell_quote(patch_tool.program) - .. " --set-interpreter " .. _shell_quote(loader) - .. " " .. _shell_quote(filepath)) - end - if ok then - result.patched = result.patched + 1 - _apply_shrink(patch_tool, filepath, shrink, result) - else - result.failed = result.failed + 1 - end - end - end -end - --- Patch directories as libraries (rpath only, no interpreter) -local function _patch_elf_libraries(patch_tool, dirs, install_dir, rpath, shrink, result) - for _, dir in ipairs(dirs) do - local full = path.is_absolute(dir) and dir or path.join(install_dir, dir) - local targets = _collect_targets(full, { include_shared_libs = true }) - for _, filepath in ipairs(targets) do - result.scanned = result.scanned + 1 - local ok = true - if rpath and rpath ~= "" then - ok = _exec_ok(_shell_quote(patch_tool.program) -)__LUA__"; - -inline constexpr std::string_view elfpatch_lua_1 = R"__LUA__( - - .. " --set-rpath " .. _shell_quote(rpath) - .. " " .. _shell_quote(filepath)) - end - if ok then - result.patched = result.patched + 1 - _apply_shrink(patch_tool, filepath, shrink, result) - else - result.failed = result.failed + 1 - end - end - end -end - -local function _patch_elf(target, opts, result) - local patch_tool = _find_tool("patchelf") - if not patch_tool then - _warn("patchelf not found, skip patching") - return result - end - - local loader = _resolve_loader(opts.loader) - local rpath = _normalize_rpath(opts.rpath) - if opts.loader and not loader then - local msg = "cannot resolve loader: " .. tostring(opts.loader) - if opts.strict then - error(msg) - end - _warn(msg) - end - - local install_dir = _RUNTIME and _RUNTIME.install_dir or target - local bins = opts.bins or (_RUNTIME and _RUNTIME.elfpatch_bins) - local libs = opts.libs or (_RUNTIME and _RUNTIME.elfpatch_libs) - - -- Custom interpreter override (absolute path, skip _resolve_loader) - local custom_interp = opts.interpreter or (_RUNTIME and _RUNTIME.elfpatch_interpreter) - if custom_interp then - loader = custom_interp - end - -- Custom rpath override (absolute paths) - local custom_rpath = opts.custom_rpath or (_RUNTIME and _RUNTIME.elfpatch_rpath) - if custom_rpath then - rpath = _normalize_rpath(custom_rpath) - end - - if bins or libs then - -- Declarative mode: package already classified bin/lib dirs - _info(string.format("declared: bins=%s libs=%s loader=%s", - bins and table.concat(bins, ",") or "nil", - libs and table.concat(libs, ",") or "nil", - tostring(loader))) - _patch_elf_executables(patch_tool, bins or {}, install_dir, loader, rpath, opts.shrink, result) - _patch_elf_libraries(patch_tool, libs or {}, install_dir, rpath, opts.shrink, result) - else - -- Fallback mode: classify each file via PT_INTERP presence so we - -- don't attempt --set-interpreter on shared libraries (which - -- legitimately have no INTERP segment, causing patchelf to exit 1 - -- and log noise). Files with INTERP get loader + rpath; files - -- without get rpath only. - -- - -- Op order: --set-rpath FIRST, then --set-interpreter. See - -- _patch_elf_executables comment for why (patchelf 0.18.0 small- - -- ELF corruption bug) and docs/plans/2026-05-03-patchelf-order- - -- bug-analysis.md for full analysis. - _info("fallback scan mode, loader=" .. tostring(loader)) - local targets = _collect_targets(target, opts) - for _, filepath in ipairs(targets) do - result.scanned = result.scanned + 1 - local any_ok = false - local has_interp = _has_pt_interp(filepath, patch_tool) - - if rpath and rpath ~= "" then - if _exec_ok(_shell_quote(patch_tool.program) - .. " --set-rpath " .. _shell_quote(rpath) - .. " " .. _shell_quote(filepath)) then - any_ok = true - end - end - if loader and has_interp then - if _exec_ok(_shell_quote(patch_tool.program) - .. " --set-interpreter " .. _shell_quote(loader) - .. " " .. _shell_quote(filepath)) then - any_ok = true - end - elseif loader and not has_interp then - -- Shared library / static binary: skip interp set silently; - -- still consider it for rpath. Don't penalize the patched - -- count if rpath alone succeeded above. - any_ok = true - end - - if any_ok then - result.patched = result.patched + 1 - _apply_shrink(patch_tool, filepath, opts.shrink, result) - else - result.failed = result.failed + 1 - end - end - end - - return result -end - -local function _patch_macho(target, opts, result) - local tool = _find_tool("install_name_tool") - if not tool then - _warn("install_name_tool not found, skip patching (try: xcode-select --install)") - return result - end - - local rpath_paths = _normalize_rpath_list(opts.rpath) - if not rpath_paths or #rpath_paths == 0 then - return result - end - - local targets = _collect_targets(target, opts) - for _, filepath in ipairs(targets) do - result.scanned = result.scanned + 1 - local ok = true - - for _, rp in ipairs(rpath_paths) do - local add_ok = _exec_ok(_shell_quote(tool.program) - .. " -add_rpath " - .. _shell_quote(rp) .. " " - .. _shell_quote(filepath)) - if not add_ok then - if opts.strict then - error("failed to add rpath " .. rp .. " for " .. filepath) - end - ok = false - end - end - - local fix_ok = true - if ok then - fix_ok = _fix_macho_dylib_refs(tool, filepath, opts) - end - if fix_ok == false and opts.strict ~= true then - ok = false - end - - if ok then - result.patched = result.patched + 1 - else - result.failed = result.failed + 1 - end - end - - return result -end - -function M.closure_lib_paths(opt) - opt = opt or {} - local values, seen = {}, {} - local function _push(p) - if p and not seen[p] then seen[p] = true; table.insert(values, p) end - end - - -- Self libdirs: prefer self_exports.libdirs (already absolute, declared - -- by the package itself); fall back to {lib64, lib} convention. - local self_libdirs = _RUNTIME and _RUNTIME.self_exports and _RUNTIME.self_exports.libdirs - if self_libdirs and #self_libdirs > 0 then - for _, d in ipairs(self_libdirs) do _push(d) end - else - local install_dir = _RUNTIME and _RUNTIME.install_dir - if install_dir then - for _, sub in ipairs({"lib64", "lib"}) do - local self_libdir = path.join(install_dir, sub) - if os.isdir(self_libdir) then _push(self_libdir); break end - end - end - end - - -- Per-dep libdirs: prefer runtime_deps_list (post-#249 split, avoids - -- build_dep RPATH pollution). Old callers passing opt.deps_list keep - -- working. For each dep, prefer deps_exports[spec].libdirs (declared - -- via the provides side) when present; fall back to {lib64, lib} - -- convention via pkginfo.dep_install_dir lookup. - local deps_list = opt.deps_list - or (_RUNTIME and (_RUNTIME.runtime_deps_list or _RUNTIME.deps_list)) - or {} - local deps_exports = _RUNTIME and _RUNTIME.deps_exports or {} - for _, dep_spec in ipairs(deps_list) do - local declared = deps_exports[dep_spec] - if declared and declared.libdirs and #declared.libdirs > 0 then - for _, d in ipairs(declared.libdirs) do _push(d) end - else - local dep_name = dep_spec:gsub("@.*", ""):gsub("^.+:", "") - local dep_version = dep_spec:find("@", 1, true) and dep_spec:match("@(.+)") or nil - local dep_dir - if _LIBXPKG_MODULES and _LIBXPKG_MODULES.pkginfo then - dep_dir = _LIBXPKG_MODULES.pkginfo.dep_install_dir(dep_name, dep_version) - end - if dep_dir then - for _, sub in ipairs({"lib64", "lib"}) do - local libdir = path.join(dep_dir, sub) - if os.isdir(libdir) then _push(libdir); break end - end - end - end - end - - local sysroot = _RUNTIME and _RUNTIME.subos_sysrootdir - if sysroot and sysroot ~= "" then _push(path.join(sysroot, "lib")) end - - return values -end - --- Low-level dispatch: pick the right binary-format toolchain. --- linux → ELF / patchelf: --set-interpreter (PT_INTERP) + --set-rpath --- macosx → Mach-O / install_name_tool: -add_rpath + dylib path rewrites; opts.loader ignored --- windows → PE has no INTERP/RPATH analog (DLL search is governed by the --- Windows loader: same dir → System32 → PATH). No-op + log. --- Higher-level entry points (M._apply, M.set / M.skip predicate path) bail --- out earlier on Windows; this dispatch is the last-line guard so direct --- callers (M.patch_elf_loader_rpath, legacy auto) stay safe too. -function M.patch_elf_loader_rpath(target, opts) - opts = opts or {} - local result = { scanned = 0, patched = 0, failed = 0, shrinked = 0, shrink_failed = 0 } - - if is_host("linux") then - return _patch_elf(target, opts, result) - elseif is_host("macosx") then - return _patch_macho(target, opts, result) - end - - _info("skipping on unsupported platform " .. tostring(os.host())) - return result -end - -function M.set_interpreter(target, interpreter, opts) - opts = opts or {} - local result = { scanned = 0, patched = 0, failed = 0 } - if not is_host("linux") then return result end - - local patch_tool = _find_tool("patchelf") - if not patch_tool then - _warn("patchelf not found") - return result - end - - local targets = _collect_targets(target, opts) - for _, filepath in ipairs(targets) do - result.scanned = result.scanned + 1 - _info("set-interpreter: " .. filepath .. " -> " .. interpreter) - if _exec_ok(_shell_quote(patch_tool.program) - .. " --set-interpreter " .. _shell_quote(interpreter) - .. " " .. _shell_quote(filepath)) then - result.patched = result.patched + 1 - else - result.failed = result.failed + 1 - end - end - return result -end - -function M.set_rpath(target, rpath, opts) - opts = opts or {} - local shrink = opts.shrink - if shrink == nil then shrink = true end - local result = { scanned = 0, patched = 0, failed = 0, shrinked = 0, shrink_failed = 0 } - - if is_host("linux") then - local patch_tool = _find_tool("patchelf") - if not patch_tool then - _warn("patchelf not found") - return result - end - local rpath_str = _normalize_rpath(rpath) - if not rpath_str or rpath_str == "" then return result end - - local targets = _collect_targets(target, opts) - for _, filepath in ipairs(targets) do - result.scanned = result.scanned + 1 - _info("set-rpath: " .. filepath) - if _exec_ok(_shell_quote(patch_tool.program) - .. " --set-rpath " .. _shell_quote(rpath_str) - .. " " .. _shell_quote(filepath)) then - result.patched = result.patched + 1 - _apply_shrink(patch_tool, filepath, shrink, result) - else - result.failed = result.failed + 1 - end - end - elseif is_host("macosx") then - return _patch_macho(target, { rpath = rpath }, result) - end - - return result -end - --- ───────────────────────────────────────────────────────────────────── --- Public API (v0.1.0+) — declarative ElfPatch --- ───────────────────────────────────────────────────────────────────── --- --- Default (consumer install hook does nothing): xlings post-install --- predicate-driven trigger applies elfpatch automatically when the --- consumer's runtime deps include a package that declared --- `xpm..exports.runtime.loader`. --- --- Hook-level overrides (in order of precedence): --- --- elfpatch.skip() → don't auto-patch this package --- elfpatch.set({...}) → use these params (predicate stays off) --- --- Override is "覆盖式" — once set is called, the predicate-driven auto --- path stops; xlings uses exactly the params provided. If you want --- partial customisation, prefer providing all required fields explicitly --- (loader / rpath) rather than mixing. --- --- Lower-level escape hatches (rare, advanced): --- elfpatch.patch_elf_loader_rpath(target, opts) manual call --- elfpatch.closure_lib_paths(opts) compute rpath only -function M.set(opts) - _RUNTIME = _RUNTIME or {} - _RUNTIME.elfpatch_user_override = true - _RUNTIME.elfpatch_user_opts = opts or {} -end - -function M.skip() - _RUNTIME = _RUNTIME or {} - _RUNTIME.elfpatch_user_skip = true -end - --- ───────────────────────────────────────────────────────────────────── --- DEPRECATED — half-year transition compat (drop after 2026-11) --- ───────────────────────────────────────────────────────────────────── --- Old API: elfpatch.auto({enable, shrink, bins, libs, interpreter, rpath}). --- Sets `_RUNTIME.elfpatch_legacy_*` flags that `M.apply_auto` reads to --- preserve the original "loader='subos' default + bins/libs whitelists" --- behavior. Don't try to remap onto the new `M.set` semantics — they --- diverge in ways that broke prior consumers (e.g. legacy `auto({enable=true})` --- without explicit interpreter implicitly meant "use system loader", --- but `set({})` with no interpreter is a no-op under the new design). --- Logs once at debug level so verbose users see migration prompts. -local _auto_warn_once = false -function M.auto(enable_or_opts) - if not _auto_warn_once then - _auto_warn_once = true - local log = _get_log() - if log then - log.debug("elfpatch.auto() is deprecated; use elfpatch.set({...}) " - .. "or elfpatch.skip(). The old API will be removed after 2026-11.") - end - end - _RUNTIME = _RUNTIME or {} - if type(enable_or_opts) == "table" then - if enable_or_opts.enable ~= nil then - _RUNTIME.elfpatch_legacy_auto = (enable_or_opts.enable == true) - end - if enable_or_opts.shrink ~= nil then - _RUNTIME.elfpatch_legacy_shrink = (enable_or_opts.shrink == true) - end - if enable_or_opts.bins then _RUNTIME.elfpatch_legacy_bins = enable_or_opts.bins end - if enable_or_opts.libs then _RUNTIME.elfpatch_legacy_libs = enable_or_opts.libs end - if enable_or_opts.interpreter then _RUNTIME.elfpatch_legacy_interpreter = enable_or_opts.interpreter end - if enable_or_opts.rpath then _RUNTIME.elfpatch_legacy_rpath = enable_or_opts.rpath end - else - _RUNTIME.elfpatch_legacy_auto = (enable_or_opts == true) - end -)__LUA__"; - -inline constexpr std::string_view elfpatch_lua_2 = R"__LUA__( - - return _RUNTIME.elfpatch_legacy_auto -end - --- Internal apply, called by xlings's apply_elfpatch_auto() after the --- install hook returns. Decision tree mirrors the design doc: --- 1. user_skip → return --- 2. user_override → use hook-given opts --- 3. self_exports.loader exists → use own loader (e.g. glibc itself) --- 4. exactly one runtime-dep with exports.loader → use it --- 5. ≥ 2 such deps → require interp_from in user_opts (fail-fast) --- 6. otherwise → no patch -function M._apply() - local empty = { scanned = 0, patched = 0, failed = 0, shrinked = 0, shrink_failed = 0 } - if not _RUNTIME then return empty end - - -- Cross-platform support matrix: - -- linux → ELF + patchelf: full INTERP + RPATH (predicate path) - -- macosx → Mach-O + install_name_tool: RPATH only; INTERP irrelevant - -- (dyld is the kernel's responsibility, no per-binary loader). - -- Predicate currently keys off `loader` so it's a no-op on - -- macosx unless a dep declares one — which is correct since - -- macOS deps shouldn't declare `loader`. Use elfpatch.set({ - -- rpath = {...} }) explicitly if rpath-only patching needed. - -- windows → PE: no INTERP, no RPATH analog. DLL search is governed by - -- Windows loader (same-dir → System32 → PATH); patchelf has - -- no equivalent. Skip the whole predicate path early. - if is_host("windows") then - local log = _get_log() - if log then log.debug("elfpatch._apply: windows host has no INTERP/RPATH analog; skipping") end - return empty - end - - if _RUNTIME.elfpatch_user_skip then - local log = _get_log() - if log then log.debug("elfpatch._apply: user skip") end - return empty - end - - local target = (_RUNTIME and _RUNTIME.install_dir) - if not target then return empty end - - -- Helper: scan runtime deps for loader providers. - local function _loader_candidates() - local cands = {} - local rt = (_RUNTIME and _RUNTIME.runtime_deps_list) or {} - local exports = (_RUNTIME and _RUNTIME.deps_exports) or {} - for _, dep_spec in ipairs(rt) do - local e = exports[dep_spec] - if e and e.loader and e.loader ~= "" then - table.insert(cands, { spec = dep_spec, loader = e.loader, abi = e.abi }) - end - end - return cands - end - - -- Predicate resolution. Returns one of: - -- { loader=, predicate_kind="self" } Rule 1: self-patch (opt-in) - -- { loader=, predicate_kind="single", abi } Rule 4: single dep with loader - -- { loader=nil, predicate_kind="ambiguous" } Rule 5: multi-loader → fail-fast - -- { loader=nil, predicate_kind="macos-rpath" } macOS fallback: rpath-only - -- nil no patch - -- - -- ▸ Rule 1 (self-patch) is OPT-IN. A loader-provider declaring - -- exports.runtime.loader is publishing metadata for *consumers* — - -- it's not asking us to rewrite its own ELF. Auto-self-patch breaks - -- ld-linux / libc.so.6 program-header invariants (segfaults at - -- execve+1 with SEGV_MAPERR @ 0x8). The provider's install hook - -- should pre-relocate its own payload at install time (e.g. - -- glibc.lua's __relocate rewrites build-host paths). Opt in via - -- elfpatch.set({ self_patch = true }) only when the package author - -- has verified the provider is safe to self-patch. - -- - -- ▸ macOS fallback: Mach-O has no INTERP analog (dyld is the kernel's - -- responsibility), so deps on macOS shouldn't declare `loader`. But - -- consumers still need RPATH closure to find dep dylibs. When no - -- loader candidate exists but at least one dep declared `libdirs`, - -- fire rpath-only on macOS. Linux deliberately doesn't have this - -- fallback — patching only RPATH leaves INTERP pointing at - -- build-host glibc, which segfaults at execve. - local function _resolve_predicate() - local user_opts = _RUNTIME.elfpatch_user_opts or {} - if user_opts.self_patch == true then - local self_loader = _RUNTIME.self_exports and _RUNTIME.self_exports.loader - if self_loader and self_loader ~= "" then - return { loader = self_loader, predicate_kind = "self" } - end - end - local cands = _loader_candidates() - if #cands == 1 then - return { loader = cands[1].loader, predicate_kind = "single", abi = cands[1].abi } - end - if #cands >= 2 then - return { loader = nil, predicate_kind = "ambiguous", candidates = cands } - end - -- 0 loader candidates. macOS-only fallback to rpath-only path. - if is_host("macosx") then - local rt = (_RUNTIME and _RUNTIME.runtime_deps_list) or {} - local exports = (_RUNTIME and _RUNTIME.deps_exports) or {} - for _, dep_spec in ipairs(rt) do - local e = exports[dep_spec] - if e and e.libdirs and #e.libdirs > 0 then - return { loader = nil, predicate_kind = "macos-rpath" } - end - end - end - return nil - end - - local effective_loader, effective_rpath, effective_shrink - local effective_scan, effective_skip, effective_extra - local source - - if _RUNTIME.elfpatch_user_override then - local u = _RUNTIME.elfpatch_user_opts or {} - if u.enable == false then return empty end - source = "user_set" - if u.interpreter and u.interpreter ~= "" then - effective_loader = u.interpreter - elseif u.interp_from and u.interp_from ~= "" then - for _, c in ipairs(_loader_candidates()) do - if c.abi == u.interp_from then effective_loader = c.loader; break end - end - if not effective_loader then - _warn("elfpatch.set: interp_from='" .. u.interp_from - .. "' did not match any runtime-dep loader provider") - return empty - end - end - effective_shrink = (u.shrink ~= nil) and u.shrink or false - effective_scan = u.scan - effective_skip = u.skip - effective_extra = u.extra_rpath or {} - else - local r = _resolve_predicate() - if not r then - local log = _get_log() - if log then log.debug("elfpatch._apply: no loader provider in deps; skipping") end - return empty - end - if r.predicate_kind == "ambiguous" then - local lines = {} - for _, c in ipairs(r.candidates) do - table.insert(lines, " - " .. c.spec .. " (abi: " .. tostring(c.abi) .. ")") - end - _warn("elfpatch._apply: multiple loader providers in runtime deps:\n" - .. table.concat(lines, "\n") - .. "\nUse elfpatch.set({ interp_from = \"\" }) in install hook to disambiguate.") - return empty - end - source = ("predicate:" .. r.predicate_kind) - effective_loader = r.loader - effective_shrink = false - effective_extra = {} - end - - -- An empty loader is only safe to proceed in two cases: - -- 1. macOS: Mach-O has no INTERP, so rpath-only is the natural patch. - -- 2. user_set: caller explicitly chose set({ rpath=... }) without an - -- interpreter — honor their explicit intent. - -- On Linux predicate path, an empty loader means we'd leave INTERP - -- pointing at build-host glibc → segfault at execve. Bail safely. - if not effective_loader or effective_loader == "" then - local platform_allows_no_loader = is_host("macosx") - local user_explicitly_chose_rpath_only = (source == "user_set") - if not (platform_allows_no_loader or user_explicitly_chose_rpath_only) then - local log = _get_log() - if log then log.debug("elfpatch._apply: no loader resolved (source=" .. tostring(source) .. ")") end - return empty - end - end - - -- Build rpath = closure(self libdirs + runtime-dep libdirs + sysroot) - -- + any extra_rpath the user added via set({...}). - effective_rpath = M.closure_lib_paths({}) - for _, p in ipairs(effective_extra or {}) do - table.insert(effective_rpath, p) - end - - local log = _get_log() - if log then - log.debug("elfpatch._apply: source=" .. source - .. " loader=" .. tostring(effective_loader)) - end - - return M.patch_elf_loader_rpath(target, { - loader = effective_loader, - rpath = effective_rpath, - shrink = effective_shrink, - scan = effective_scan, - skip = effective_skip, - }) -end - --- Legacy apply path: behaves exactly like the pre-rewrite `apply_auto` --- (loader = "subos" by default, bins/libs whitelists, etc). Used when --- the install hook called the deprecated `M.auto({...})` API. -local function _legacy_apply(opts) - opts = opts or {} - if not (_RUNTIME and _RUNTIME.elfpatch_legacy_auto) then - return { scanned = 0, patched = 0, failed = 0, shrinked = 0, shrink_failed = 0 } - end - - local target = opts.target or (_RUNTIME and _RUNTIME.install_dir) - local rpath = opts.rpath - or (_RUNTIME and _RUNTIME.elfpatch_legacy_rpath) - or M.closure_lib_paths({ - -- Old behavior: deps_list was the union; legacy callers - -- expect that closure. Don't switch to runtime_deps_list - -- here or it'll silently change behavior. - deps_list = _RUNTIME and _RUNTIME.deps_list, - }) - local shrink = opts.shrink - if shrink == nil then - shrink = _RUNTIME and _RUNTIME.elfpatch_legacy_shrink == true - end - local loader = opts.loader - or (_RUNTIME and _RUNTIME.elfpatch_legacy_interpreter) - or "subos" - - return M.patch_elf_loader_rpath(target, { - loader = loader, - rpath = rpath, - shrink = shrink, - bins = _RUNTIME and _RUNTIME.elfpatch_legacy_bins, - libs = _RUNTIME and _RUNTIME.elfpatch_legacy_libs, - include_shared_libs = opts.include_shared_libs, - recurse = opts.recurse, - strict = opts.strict, - }) -end - --- xlings's apply_elfpatch_auto bridge. Routes between legacy and new --- behaviors: --- 1. user_skip → return (highest priority, both old/new) --- 2. user_override (set) → new predicate-aware override path --- 3. legacy_auto (auto) → legacy "loader=subos default" path --- 4. neither → new predicate-driven default -function M.apply_auto(opts) - if _RUNTIME and _RUNTIME.elfpatch_user_skip then - return { scanned = 0, patched = 0, failed = 0, shrinked = 0, shrink_failed = 0 } - end - if _RUNTIME and _RUNTIME.elfpatch_user_override then - return M._apply() - end - if _RUNTIME and _RUNTIME.elfpatch_legacy_auto then - return _legacy_apply(opts) - end - -- Predicate-driven default: only kicks in if a runtime-dep declared - -- exports.runtime.loader. Otherwise no-op. - return M._apply() -end - --- Legacy queries used by some packages; map to the legacy state for --- packages still on M.auto, otherwise to the new override state. -function M.is_auto() - if _RUNTIME and _RUNTIME.elfpatch_legacy_auto ~= nil then - return _RUNTIME.elfpatch_legacy_auto == true - end - return not (_RUNTIME and _RUNTIME.elfpatch_user_skip) -end -function M.is_shrink() - if _RUNTIME and _RUNTIME.elfpatch_legacy_shrink ~= nil then - return _RUNTIME.elfpatch_legacy_shrink == true - end - if _RUNTIME and _RUNTIME.elfpatch_user_opts then - return _RUNTIME.elfpatch_user_opts.shrink == true - end - return false -end - -return M - -)__LUA__"; - -inline const std::string elfpatch_lua_storage = []{ std::string s; - s += elfpatch_lua_0; - s += elfpatch_lua_1; - s += elfpatch_lua_2; - return s; }(); -inline const std::string_view elfpatch_lua = elfpatch_lua_storage; - -inline constexpr std::string_view json_lua = R"__LUA__( --- xim.libxpkg.json: lightweight pure-Lua JSON encoder/decoder -local M = {} - --- ---- Decoder ---- - -local function skip_whitespace(s, pos) - return s:match("^%s*()", pos) -end - -local function decode_error(s, pos, msg) - local line = 1 - for _ in s:sub(1, pos):gmatch("\n") do line = line + 1 end - error(string.format("json decode error at line %d, pos %d: %s", line, pos, msg)) -end - -local escape_map = { - ['"'] = '"', ['\\'] = '\\', ['/'] = '/', - ['b'] = '\b', ['f'] = '\f', ['n'] = '\n', ['r'] = '\r', ['t'] = '\t', -} - -local function decode_string(s, pos) - pos = pos + 1 -- skip opening quote - local buf = {} - while pos <= #s do - local c = s:sub(pos, pos) - if c == '"' then - return table.concat(buf), pos + 1 - elseif c == '\\' then - pos = pos + 1 - local esc = s:sub(pos, pos) - if escape_map[esc] then - table.insert(buf, escape_map[esc]) - pos = pos + 1 - elseif esc == 'u' then - local hex = s:sub(pos + 1, pos + 4) - local cp = tonumber(hex, 16) - if not cp then decode_error(s, pos, "invalid unicode escape") end - if cp < 0x80 then - table.insert(buf, string.char(cp)) - elseif cp < 0x800 then - table.insert(buf, string.char( - 0xC0 + math.floor(cp / 64), - 0x80 + (cp % 64) - )) - else - table.insert(buf, string.char( - 0xE0 + math.floor(cp / 4096), - 0x80 + math.floor((cp % 4096) / 64), - 0x80 + (cp % 64) - )) - end - pos = pos + 5 - else - decode_error(s, pos, "invalid escape: \\" .. esc) - end - else - table.insert(buf, c) - pos = pos + 1 - end - end - decode_error(s, pos, "unterminated string") -end - -local function decode_number(s, pos) - local num_str = s:match("^-?%d+%.?%d*[eE]?[+-]?%d*", pos) - if not num_str then decode_error(s, pos, "invalid number") end - local val = tonumber(num_str) - if not val then decode_error(s, pos, "invalid number: " .. num_str) end - return val, pos + #num_str -end - -local decode_value -- forward declaration - -local function decode_array(s, pos) - pos = pos + 1 -- skip '[' - local arr = {} - pos = skip_whitespace(s, pos) - if s:sub(pos, pos) == ']' then return arr, pos + 1 end - while true do - local val - val, pos = decode_value(s, pos) - table.insert(arr, val) - pos = skip_whitespace(s, pos) - local c = s:sub(pos, pos) - if c == ']' then return arr, pos + 1 end - if c ~= ',' then decode_error(s, pos, "expected ',' or ']'") end - pos = skip_whitespace(s, pos + 1) - end -end - -local function decode_object(s, pos) - pos = pos + 1 -- skip '{' - local obj = {} - pos = skip_whitespace(s, pos) - if s:sub(pos, pos) == '}' then return obj, pos + 1 end - while true do - pos = skip_whitespace(s, pos) - if s:sub(pos, pos) ~= '"' then decode_error(s, pos, "expected string key") end - local key - key, pos = decode_string(s, pos) - pos = skip_whitespace(s, pos) - if s:sub(pos, pos) ~= ':' then decode_error(s, pos, "expected ':'") end - pos = skip_whitespace(s, pos + 1) - local val - val, pos = decode_value(s, pos) - obj[key] = val - pos = skip_whitespace(s, pos) - local c = s:sub(pos, pos) - if c == '}' then return obj, pos + 1 end - if c ~= ',' then decode_error(s, pos, "expected ',' or '}'") end - pos = pos + 1 - end -end - -decode_value = function(s, pos) - pos = skip_whitespace(s, pos) - local c = s:sub(pos, pos) - if c == '"' then return decode_string(s, pos) end - if c == '{' then return decode_object(s, pos) end - if c == '[' then return decode_array(s, pos) end - if c == 't' then - if s:sub(pos, pos + 3) == "true" then return true, pos + 4 end - decode_error(s, pos, "invalid value") - end - if c == 'f' then - if s:sub(pos, pos + 4) == "false" then return false, pos + 5 end - decode_error(s, pos, "invalid value") - end - if c == 'n' then - if s:sub(pos, pos + 3) == "null" then return nil, pos + 4 end - decode_error(s, pos, "invalid value") - end - if c == '-' or (c >= '0' and c <= '9') then return decode_number(s, pos) end - decode_error(s, pos, "unexpected character: " .. c) -end - -function M.decode(str) - if type(str) ~= "string" then error("json.decode: expected string, got " .. type(str)) end - local val, pos = decode_value(str, 1) - return val -end - --- ---- Encoder ---- - -local function is_array(t) - local max_i = 0 - local count = 0 - for k, _ in pairs(t) do - if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then return false end - if k > max_i then max_i = k end - count = count + 1 - end - return max_i == count -end - -local encode_value -- forward declaration - -local escape_char_map = { - ['\\'] = '\\\\', ['"'] = '\\"', ['\b'] = '\\b', - ['\f'] = '\\f', ['\n'] = '\\n', ['\r'] = '\\r', ['\t'] = '\\t', -} - -local function encode_string(val) - return '"' .. val:gsub('[\\"\b\f\n\r\t]', escape_char_map):gsub( - "[\x00-\x1f]", function(c) return string.format("\\u%04x", c:byte()) end - ) .. '"' -end - -local function encode_array(arr, indent, level) - if #arr == 0 then return "[]" end - local items = {} - for _, v in ipairs(arr) do - table.insert(items, encode_value(v, indent, level)) - end - if indent then - local pad = string.rep(indent, level) - local inner_pad = string.rep(indent, level + 1) - return "[\n" .. inner_pad .. table.concat(items, ",\n" .. inner_pad) .. "\n" .. pad .. "]" - end - return "[" .. table.concat(items, ",") .. "]" -end - -local function encode_object(obj, indent, level) - local keys = {} - for k in pairs(obj) do table.insert(keys, k) end - if #keys == 0 then return "{}" end - table.sort(keys) - local items = {} - for _, k in ipairs(keys) do - local key_str = encode_string(tostring(k)) - local val_str = encode_value(obj[k], indent, level) - if indent then - table.insert(items, key_str .. ": " .. val_str) - else - table.insert(items, key_str .. ":" .. val_str) - end - end - if indent then - local pad = string.rep(indent, level) - local inner_pad = string.rep(indent, level + 1) - return "{\n" .. inner_pad .. table.concat(items, ",\n" .. inner_pad) .. "\n" .. pad .. "}" - end - return "{" .. table.concat(items, ",") .. "}" -end - -encode_value = function(val, indent, level) - level = level or 0 - local vtype = type(val) - if val == nil then return "null" end - if vtype == "boolean" then return val and "true" or "false" end - if vtype == "number" then - if val ~= val then return "null" end -- NaN - if val == math.huge or val == -math.huge then return "null" end - if val == math.floor(val) and math.abs(val) < 1e15 then - return string.format("%d", val) - end - return tostring(val) - end - if vtype == "string" then return encode_string(val) end - if vtype == "table" then - if is_array(val) then - return encode_array(val, indent, level) - else - return encode_object(val, indent, level) - end - end - error("json.encode: unsupported type: " .. vtype) -end - -function M.encode(val, opts) - opts = opts or {} - local indent = opts.indent and (type(opts.indent) == "string" and opts.indent or " ") or nil - return encode_value(val, indent, 0) -end - --- File convenience functions (xmake compat: json.loadfile / json.savefile) -function M.loadfile(filepath) - local f = io.open(filepath, "r") - if not f then return nil end - local content = f:read("*a") - f:close() - if not content or content == "" then return nil end - local ok, val = pcall(M.decode, content) - if not ok then return nil end - return val -end - -function M.savefile(filepath, val, opts) - local content = M.encode(val, opts) - local f = io.open(filepath, "w") - if not f then return false end - f:write(content) - f:write("\n") - f:close() - return true -end - -return M - -)__LUA__"; - -inline constexpr std::string_view base64_lua = R"__LUA__( --- xim.libxpkg.base64: pure-Lua base64 encoder/decoder -local M = {} - -local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" -local b64lookup = {} -for i = 1, #b64chars do - b64lookup[b64chars:sub(i, i)] = i - 1 -end - -function M.encode(data) - if type(data) ~= "string" then data = tostring(data) end - local result = {} - local len = #data - for i = 1, len, 3 do - local b1 = data:byte(i) - local b2 = i + 1 <= len and data:byte(i + 1) or 0 - local b3 = i + 2 <= len and data:byte(i + 2) or 0 - - local n = b1 * 65536 + b2 * 256 + b3 - - table.insert(result, b64chars:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1)) - table.insert(result, b64chars:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1)) - if i + 1 <= len then - table.insert(result, b64chars:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1)) - else - table.insert(result, "=") - end - if i + 2 <= len then - table.insert(result, b64chars:sub(n % 64 + 1, n % 64 + 1)) - else - table.insert(result, "=") - end - end - return table.concat(result) -end - -function M.decode(data) - if type(data) ~= "string" then return "" end - data = data:gsub("[^A-Za-z0-9+/=]", "") - local result = {} - for i = 1, #data, 4 do - local c1 = b64lookup[data:sub(i, i)] or 0 - local c2 = b64lookup[data:sub(i + 1, i + 1)] or 0 - local c3 = b64lookup[data:sub(i + 2, i + 2)] - local c4 = b64lookup[data:sub(i + 3, i + 3)] - - local n = c1 * 262144 + c2 * 4096 - table.insert(result, string.char(math.floor(n / 65536) % 256)) - - if c3 then - n = n + c3 * 64 - table.insert(result, string.char(math.floor(n / 256) % 256)) - end - if c4 then - n = n + c4 - table.insert(result, string.char(n % 256)) - end - end - return table.concat(result) -end - -return M - -)__LUA__"; - -} // namespace mcpplibs::xpkg::detail diff --git a/tests/test_executor.cpp b/tests/test_executor.cpp index 4c11828..8692926 100644 --- a/tests/test_executor.cpp +++ b/tests/test_executor.cpp @@ -1125,7 +1125,8 @@ TEST(ExecutorTest, ApplyInstallStamp_IsIdempotent) { namespace { // Write a recipe whose config() hook is `body`, and return its ops. -std::vector ops_from_config(const fs::path& dir, const char* body) { +std::vector ops_from_config(const fs::path& dir, const char* body, + const char* extra_imports = "") { fs::create_directories(dir); auto pkg = dir / "opsfixture.lua"; std::string lua = @@ -1133,8 +1134,9 @@ std::vector ops_from_config(const fs::path& dir, const char* body) { " xpm = { linux = { [\"1.0.0\"] = {} },\n" " macosx = { [\"1.0.0\"] = {} },\n" " windows = { [\"1.0.0\"] = {} } } }\n" - "import(\"xim.libxpkg.xvm\")\n" - "function config()\n"; + "import(\"xim.libxpkg.xvm\")\n"; + lua += extra_imports; + lua += "function config()\n"; lua += body; lua += "\n return true\nend\n"; std::ofstream(pkg) << lua; @@ -1215,3 +1217,130 @@ TEST(ExecutorTest, XvmAdd_OmittingTheNewFieldsLeavesThemEmpty) { EXPECT_EQ(ops[0].binding, "root@1.0.0"); fs::remove_all(dir); } + +// ── subos.env: subos-scoped environment declarations ───────────────────── +// +// A separate op kind rather than more fields on `add`, because the scope is +// different: `envs` on an add is what one program shim exports for itself, +// and the program that has to see LIBGL_DRIVERS_PATH is the user's own +// binary, which xlings never wraps. + +namespace { + +constexpr const char* SUBOS_IMPORT = "import(\"xim.libxpkg.subos\")\n"; + +} // namespace + +TEST(ExecutorTest, SubosEnv_RecordsASetDeclaration) { + auto dir = fs::temp_directory_path() / "libxpkg_subos_env_set"; + fs::remove_all(dir); + auto ops = ops_from_config(dir, + " subos.env({ var = \"LIBGL_DRIVERS_PATH\", op = \"set\",\n" + " value = \"${pkgdir}/lib/dri\",\n" + " binding = \"compat.mesa@25.0.0\" })", + SUBOS_IMPORT); + + ASSERT_EQ(ops.size(), 1u); + EXPECT_EQ(ops[0].op, "subos_env"); + EXPECT_EQ(ops[0].var, "LIBGL_DRIVERS_PATH"); + // The recipe's `op` argument travels as `mode`; `op` is the category the + // consumer dispatches on and was already taken. + EXPECT_EQ(ops[0].mode, "set"); + EXPECT_EQ(ops[0].value, "${pkgdir}/lib/dri"); + EXPECT_EQ(ops[0].binding, "compat.mesa@25.0.0"); + fs::remove_all(dir); +} + +TEST(ExecutorTest, SubosEnv_RecordsAPrependDeclaration) { + auto dir = fs::temp_directory_path() / "libxpkg_subos_env_prepend"; + fs::remove_all(dir); + auto ops = ops_from_config(dir, + " subos.env({ var = \"XDG_DATA_DIRS\", op = \"prepend\",\n" + " value = \"${pkgdir}/share\",\n" + " binding = \"compat.mesa@25.0.0\" })", + SUBOS_IMPORT); + + ASSERT_EQ(ops.size(), 1u); + EXPECT_EQ(ops[0].mode, "prepend"); + EXPECT_EQ(ops[0].var, "XDG_DATA_DIRS"); + fs::remove_all(dir); +} + +TEST(ExecutorTest, SubosEnv_DefaultsOpToSetAndBindingToThePackage) { + auto dir = fs::temp_directory_path() / "libxpkg_subos_env_defaults"; + fs::remove_all(dir); + auto ops = ops_from_config(dir, + " subos.env({ var = \"MESA_LOADER_DRIVER_OVERRIDE\",\n" + " value = \"swrast\" })", + SUBOS_IMPORT); + + ASSERT_EQ(ops.size(), 1u); + EXPECT_EQ(ops[0].mode, "set"); + EXPECT_EQ(ops[0].binding, "opsfixture@1.0.0"); + fs::remove_all(dir); +} + +TEST(ExecutorTest, SubosEnv_RejectsAnOpThisClientDoesNotImplement) { + auto dir = fs::temp_directory_path() / "libxpkg_subos_env_badop"; + fs::remove_all(dir); + // `append` is a documented future op. Recording it as if it were `set` + // would put a value in the manifest that no one asked for; dropping it + // silently would be the same bug one layer down. It is refused, and the + // recipe sees `false`. + auto ops = ops_from_config(dir, + " local ok = subos.env({ var = \"PATH\", op = \"append\",\n" + " value = \"${pkgdir}/bin\" })\n" + " xvm.add(\"probe.returned.\" .. tostring(ok))", + SUBOS_IMPORT); + + ASSERT_EQ(ops.size(), 1u); + EXPECT_EQ(ops[0].name, "probe.returned.false"); + fs::remove_all(dir); +} + +TEST(ExecutorTest, SubosEnv_RejectsAMissingVariableName) { + auto dir = fs::temp_directory_path() / "libxpkg_subos_env_novar"; + fs::remove_all(dir); + auto ops = ops_from_config(dir, + " local ok = subos.env({ value = \"anything\" })\n" + " xvm.add(\"probe.returned.\" .. tostring(ok))", + SUBOS_IMPORT); + + ASSERT_EQ(ops.size(), 1u); + EXPECT_EQ(ops[0].name, "probe.returned.false"); + fs::remove_all(dir); +} + +// The capability probe a recipe has to write, and the reason it cannot be +// spelled the obvious way. +// +// import() answers an unknown module with a permissive proxy: every key read +// off it is a truthy callable table. So on a client that predates this module, +// `if subos.env then` is *true*, the recipe takes the new branch, and the call +// evaporates -- install succeeds, nothing is configured, nothing complains. +// +// `if xvm.files then` in the V2 spec is safe only because `xvm` is a module +// those clients already ship, so the missing *field* really is nil. A missing +// *module* never is. type() is what separates them. +TEST(ExecutorTest, SubosEnv_ProbeMustTestTypeBecauseUnknownModulesAreTruthy) { + auto dir = fs::temp_directory_path() / "libxpkg_subos_env_probe"; + fs::remove_all(dir); + auto ops = ops_from_config(dir, + " xvm.add(\"real.truthy.\" .. tostring(subos.env ~= nil))\n" + " xvm.add(\"real.typed.\" .. tostring(type(subos.env) == \"function\"))\n" + " xvm.add(\"absent.truthy.\" .. tostring(notyet.env ~= nil))\n" + " xvm.add(\"absent.typed.\" .. tostring(type(notyet.env) == \"function\"))", + "import(\"xim.libxpkg.subos\")\n" + "import(\"xim.libxpkg.notyet\")\n"); + + ASSERT_EQ(ops.size(), 4u); + EXPECT_EQ(ops[0].name, "real.truthy.true"); + EXPECT_EQ(ops[1].name, "real.typed.true"); + // Both of these are the point: the truthiness test cannot tell a stub from + // a real module, and the type test can. + EXPECT_EQ(ops[2].name, "absent.truthy.true") + << "if import() ever stopped stubbing unknown modules, the probe rule " + "in the V2 spec could be relaxed -- until then it must stay"; + EXPECT_EQ(ops[3].name, "absent.typed.false"); + fs::remove_all(dir); +} diff --git a/tests/xmake.lua b/tests/xmake.lua deleted file mode 100644 index 1970b1c..0000000 --- a/tests/xmake.lua +++ /dev/null @@ -1,48 +0,0 @@ -add_requires("gtest") - --- Data model tests — only mcpplibs.xpkg, no external deps -target("xpkg_model_test") - set_kind("binary") - add_files("main.cpp", "test_model.cpp") - add_deps("mcpplibs-xpkg") - add_packages("gtest") - set_policy("build.c++.modules", true) - --- Loader tests — mcpplibs.xpkg + mcpplibs.xpkg.loader (needs lua) -target("xpkg_loader_test") - set_kind("binary") - add_files("main.cpp", "test_loader.cppm") - add_deps("mcpplibs-xpkg", "mcpplibs-xpkg-loader") - add_packages("gtest", "mcpplibs-capi-lua") - set_policy("build.c++.modules", true) - on_config(function(target) - -- Normalize to forward slashes: Windows $(projectdir) contains backslashes - -- which are misinterpreted as C escape sequences in string literals. - local dir = path.join(os.projectdir(), "tests", "fixtures", "pkgindex") - dir = dir:gsub("\\", "/") - target:add("defines", 'XPKG_TEST_PKGINDEX="' .. dir .. '"') - local build_dir = path.join(os.projectdir(), "tests", "fixtures", "pkgindex-build") - build_dir = build_dir:gsub("\\", "/") - target:add("defines", 'XPKG_TEST_PKGINDEX_BUILD="' .. build_dir .. '"') - end) - --- Index tests — mcpplibs.xpkg + mcpplibs.xpkg.index, pure C++ -target("xpkg_index_test") - set_kind("binary") - add_files("main.cpp", "test_index.cpp") - add_deps("mcpplibs-xpkg", "mcpplibs-xpkg-index") - add_packages("gtest") - set_policy("build.c++.modules", true) - --- Executor tests — mcpplibs.xpkg + mcpplibs.xpkg.executor (needs lua) -target("xpkg_executor_test") - set_kind("binary") - add_files("main.cpp", "test_executor.cpp") - add_deps("mcpplibs-xpkg", "mcpplibs-xpkg-executor") - add_packages("gtest", "mcpplibs-capi-lua") - set_policy("build.c++.modules", true) - on_config(function(target) - local dir = path.join(os.projectdir(), "tests", "fixtures", "pkgindex") - dir = dir:gsub("\\", "/") - target:add("defines", 'XPKG_TEST_PKGINDEX="' .. dir .. '"') - end) diff --git a/xmake.lua b/xmake.lua deleted file mode 100644 index 097dfac..0000000 --- a/xmake.lua +++ /dev/null @@ -1,120 +0,0 @@ -add_rules("mode.debug", "mode.release") -set_languages("c++23") - -add_repositories("mcpplibs-index https://github.com/mcpplibs/mcpplibs-index.git") -add_requires("mcpplibs-capi-lua 0.0.2") - --- Data model (zero external deps) -target("mcpplibs-xpkg") - set_kind("static") - add_files("src/xpkg.cppm", {public = true, install = true}) - set_policy("build.c++.modules", true) - --- Loader (parses .lua package files) -target("mcpplibs-xpkg-loader") - set_kind("static") - add_deps("mcpplibs-xpkg") - add_packages("mcpplibs-capi-lua", {public = true}) - add_files("src/xpkg-loader.cppm", {public = true, install = true}) - set_policy("build.c++.modules", true) - --- Index (pure C++ operations) -target("mcpplibs-xpkg-index") - set_kind("static") - add_deps("mcpplibs-xpkg") - add_files("src/xpkg-index.cppm", {public = true, install = true}) - set_policy("build.c++.modules", true) - --- Lua stdlib module (generated from src/lua-stdlib/ at build time) -target("mcpplibs-xpkg-lua-stdlib") - set_kind("static") - add_files("src/xpkg-lua-stdlib.cppm", {public = true, install = true}) - set_policy("build.c++.modules", true) - before_build(function(target) - local proj = target:scriptdir() - local outfile = path.join(proj, "src/xpkg-lua-stdlib.cppm") - local f = io.open(outfile, "w") - f:write("// Auto-generated by xmake before_build — do not edit manually\n") - f:write("module;\n") - f:write("export module mcpplibs.xpkg.lua_stdlib;\n") - f:write("import std;\n\n") - f:write("export namespace mcpplibs::xpkg::detail {\n\n") - - -- MSVC C2026: string literal max 16380 bytes; split large files - local MSVC_LIMIT = 15000 - local function embed(varname, rel_path) - local content = io.readfile(path.join(proj, rel_path)) - if #content <= MSVC_LIMIT then - f:write("inline constexpr std::string_view " .. varname) - f:write(" = R\"__LUA__(\n") - f:write(content) - f:write("\n)__LUA__\";\n\n") - else - -- Split into chunks joined via operator+ - local chunks = {} - local pos = 1 - local idx = 0 - while pos <= #content do - local chunk = content:sub(pos, pos + MSVC_LIMIT - 1) - -- Avoid splitting in the middle of a line - if pos + MSVC_LIMIT - 1 < #content then - local nl = chunk:reverse():find("\n") - if nl and nl < MSVC_LIMIT then - chunk = content:sub(pos, pos + MSVC_LIMIT - nl - 1) - end - end - local cname = varname .. "_" .. idx - f:write("inline constexpr std::string_view " .. cname) - f:write(" = R\"__LUA__(\n") - f:write(chunk) - f:write("\n)__LUA__\";\n\n") - chunks[#chunks + 1] = cname - pos = pos + #chunk - idx = idx + 1 - end - -- Concatenate via a function - f:write("inline const std::string " .. varname .. "_storage = []{ std::string s;\n") - for _, c in ipairs(chunks) do - f:write(" s += " .. c .. ";\n") - end - f:write(" return s; }();\n") - f:write("inline const std::string_view " .. varname) - f:write(" = " .. varname .. "_storage;\n\n") - end - end - - embed("prelude_lua", "src/lua-stdlib/prelude.lua") - embed("log_lua", "src/lua-stdlib/xim/libxpkg/log.lua") - embed("pkginfo_lua", "src/lua-stdlib/xim/libxpkg/pkginfo.lua") - embed("system_lua", "src/lua-stdlib/xim/libxpkg/system.lua") - embed("xvm_lua", "src/lua-stdlib/xim/libxpkg/xvm.lua") - embed("utils_lua", "src/lua-stdlib/xim/libxpkg/utils.lua") - embed("pkgmanager_lua", "src/lua-stdlib/xim/libxpkg/pkgmanager.lua") - embed("elfpatch_lua", "src/lua-stdlib/xim/libxpkg/elfpatch.lua") - embed("json_lua", "src/lua-stdlib/xim/libxpkg/json.lua") - embed("base64_lua", "src/lua-stdlib/xim/libxpkg/base64.lua") - - f:write("} // namespace mcpplibs::xpkg::detail\n") - f:close() - cprint("${green}[xpkg]${clear} generated src/xpkg-lua-stdlib.cppm") - end) - --- Executor (runs package hooks via Lua) -target("mcpplibs-xpkg-executor") - set_kind("static") - add_deps("mcpplibs-xpkg", "mcpplibs-xpkg-lua-stdlib") - add_packages("mcpplibs-capi-lua", {public = true}) - add_files("src/xpkg-executor.cppm", {public = true, install = true}) - set_policy("build.c++.modules", true) - --- Aggregate target: add_deps("xpkg") to get all modules -target("xpkg") - set_kind("phony") - add_deps("mcpplibs-xpkg", "mcpplibs-xpkg-loader", - "mcpplibs-xpkg-index", "mcpplibs-xpkg-lua-stdlib", - "mcpplibs-xpkg-executor") - add_packages("mcpplibs-capi-lua", {public = true}) - -if not is_host("macosx") then - includes("examples", "tests") -end