//! Embedded-luarocks runtime bootstrap. //! //! Responsibilities at startup (per LUA_MAKEOVER.md steps 3-5 and Q1-Q5): //! //! 1. Resolve `$PANTO_HOME` and the per-Lua-version rocks tree //! (`panto_home.zig`). Create the directory layout if missing. //! 2. Stage Lua headers under `/include/` (from `@embedFile`) //! so luarocks can compile C rocks against them. Idempotent: a //! file is only rewritten if its checksum differs. //! 3. Materialize `/etc/luarocks/config-.lua` with the //! pinned interpreter, rock trees, and toolchain variables. //! 4. Install a `package.searcher` that serves `require("luarocks.*")` //! and `require("compat53.*")` from embedded sources \u2014 the //! luarocks Lua libraries never touch disk. //! 5. Inject `luarocks.core.hardcoded` into `package.loaded` with the //! runtime-resolved `SYSCONFDIR`. Without this, `luarocks.core.cfg` //! can't find its config file. //! 6. Configure `package.path` / `package.cpath` so user rocks //! installed in `/share/lua/` and //! `/lib/lua/` are visible to `require`. //! 7. Reconcile the batteries manifest: for each pinned rock, check //! `/lib/luarocks/rocks-///` and invoke //! `luarocks install` for anything missing. (Slow path; only the //! first run after a fresh `$PANTO_HOME` actually downloads.) //! //! Step 7 needs a usable `lua` executable on PATH from luarocks's point //! of view \u2014 it shells out for rockspec build scripts. We satisfy //! this via the `panto lua` subcommand, addressed by writing //! ` lua` (with the absolute path of the running `panto` //! binary) into the luarocks config as `variables.LUA`. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const manifest = @import("manifest.zig"); const panto_home = @import("panto_home.zig"); const embedded_luarocks = @import("embedded_luarocks"); const embedded_lua_headers = @import("embedded_lua_headers"); const embedded_agent = @import("embedded_agent"); const embedded_panto_so = @import("embedded_panto_so"); const lua_bridge = @import("lua_bridge.zig"); const c = lua_bridge.c; /// Owned state for the runtime side of luarocks. Holds onto the /// resolved layout, the `lua_State` we attached to, and a hash map /// used by the embedded-module searcher. pub const LuarocksRuntime = struct { allocator: Allocator, layout: panto_home.Layout, L: *c.lua_State, /// Module-name to source-bytes lookup for the embedded-source /// `package.searcher` callback. Keys borrow from /// `embedded_luarocks.files`; values likewise. modules: std.StringHashMapUnmanaged([]const u8), pub fn deinit(self: *LuarocksRuntime) void { self.modules.deinit(self.allocator); self.layout.deinit(); self.allocator.destroy(self); } }; /// Errors surfaced by the bootstrap pipeline. The `Luarocks*` variants /// indicate that we were able to invoke luarocks but it exited non-zero /// (or otherwise failed); the message is in stderr at that point. pub const BootstrapError = error{ HeadersMissing, LuarocksInjectionFailed, LuarocksInstallFailed, LuarocksSearcherInstallFailed, PathConfigFailed, PantoExecutablePathUnknown, } || Allocator.Error; /// Full startup-time bootstrap. Walks the entire setup pipeline against /// the given `lua_State`, leaving it ready for callers to `require` /// luarocks modules and for any user code to find rocks under the /// configured tree. /// /// `panto_executable_path` is the absolute path of the currently /// running `panto` binary. Used to construct the ` lua` /// invocation that luarocks will use as its interpreter when running /// rockspec build scripts. /// /// Wipe the current Lua-version's rocks tree before bootstrapping. /// Used by `panto bootstrap --force` to recover from a corrupted or /// outdated installation. Only the `/rocks/lua-/` /// subtree is removed; sibling trees from other Lua versions stay /// untouched, matching the rationale in Q3 (each Lua version owns /// its own tree for clean rollback). pub fn wipeTree( allocator: Allocator, io: Io, environ_map: *const std.process.Environ.Map, ) !void { var layout = try panto_home.resolve(allocator, environ_map); defer layout.deinit(); // `deleteTree` is the cwd-relative entrypoint; we want absolute. // Open the parent and delete by basename to avoid path-traversal // surprises if `layout.tree` ever contains a symlink. const parent = std.fs.path.dirname(layout.tree) orelse { std.log.warn("panto bootstrap --force: tree has no parent? '{s}'", .{layout.tree}); return; }; const base = std.fs.path.basename(layout.tree); var parent_dir = Io.Dir.cwd().openDir(io, parent, .{}) catch |err| switch (err) { error.FileNotFound => return, // already gone; nothing to wipe else => return err, }; defer parent_dir.close(io); // `deleteTree` does not raise `FileNotFound` — a missing leaf is // treated as success, matching our "force wipe is idempotent" // intent here. try parent_dir.deleteTree(io, base); std.log.info("panto bootstrap --force: removed {s}", .{layout.tree}); } /// Every `panto` invocation runs through this one pipeline — `panto /// lua`, `panto bootstrap`, and the default agent loop are all just /// surfaces on top of "start the Lua runtime, install missing /// batteries, then do your thing." pub fn bootstrap( allocator: Allocator, io: Io, environ_map: *const std.process.Environ.Map, L: *c.lua_State, panto_executable_path: []const u8, ) !*LuarocksRuntime { const layout = try panto_home.resolve(allocator, environ_map); errdefer layout.deinit(); try panto_home.ensureDirsExist(layout, io); try stageLuaHeaders(allocator, io, layout); try stageAgentTree(allocator, io, layout); try stagePantoModule(allocator, io, layout); try stageDefaultConfig(allocator, io, layout); const lua_wrapper_path = try writeLuaWrapper(allocator, io, layout, panto_executable_path); defer allocator.free(lua_wrapper_path); try writeLuarocksConfig(allocator, io, layout, lua_wrapper_path); // The embedded-source `package.searcher` keeps a pointer to the // module map; we need the map's storage to live at a stable // address for the process lifetime. Build the runtime first so // `self.modules` is the address the singleton can capture, then // install the searcher pointing at `self.modules` specifically. const self = try allocator.create(LuarocksRuntime); errdefer allocator.destroy(self); self.* = .{ .allocator = allocator, .layout = layout, .L = L, .modules = .empty, }; try self.modules.ensureTotalCapacity(allocator, @intCast(embedded_luarocks.files.len + 2)); for (embedded_luarocks.files) |entry| { self.modules.putAssumeCapacityNoClobber(entry.name, entry.contents); } try installEmbeddedSearcher(L, &self.modules); try injectHardcoded(L, layout); try configurePackagePaths(allocator, L, layout); // Reconcile the batteries manifest. This may take a while on a // fresh install; subsequent runs no-op. Runs in-process — we // already have luarocks loaded in `L` via the embedded searcher, // so there's no need (and no reason) to spawn a child Lua. // // `PANTO_BOOTSTRAP_NO_RECONCILE` is a re-entry guard. When the // reconcile loop is already running in an ancestor process (a // luarocks build step shells out to `/bin/lua`, which is // our `panto lua` wrapper), we don't want the child to start its // own reconcile. The variable is set by `reconcileBatteries` // before any potentially-recursive work and cleared afterward. if (environ_map.get("PANTO_BOOTSTRAP_NO_RECONCILE") == null) { try reconcileBatteries(allocator, io, self); } return self; } // --------------------------------------------------------------------------- // Step 2: stage Lua headers // --------------------------------------------------------------------------- /// Write every embedded Lua header to `/include/`. Skips any file /// whose existing on-disk contents already match \u2014 keeps file mtimes /// stable across reruns and lets luarocks's mtime-based caching work. fn stageLuaHeaders( allocator: Allocator, io: Io, layout: panto_home.Layout, ) !void { var dir = try Io.Dir.cwd().openDir(io, layout.include_dir, .{}); defer dir.close(io); for (embedded_lua_headers.files) |entry| { try writeIfDifferent(allocator, io, dir, entry.name, entry.contents); } } // --------------------------------------------------------------------------- // Stage the embedded native `panto.so` // --------------------------------------------------------------------------- /// Write the binary-embedded `libpanto-lua` shared object to /// `/lib/lua//panto.so`, which is already on `package.cpath` /// (see `configurePackagePaths`). This guarantees the embedded VM's /// `require('panto')` resolves to the native agent/stream module on a /// cold, network-less machine — the module is *ours* and version-locked /// to this binary's Lua, so unlike luv it cannot be fetched from luarocks. /// /// The `.so` leaves `lua_*` undefined and resolves them against the host /// binary at `dlopen` time, identical to how a luarocks-installed C rock /// (e.g. luv) works — `exe.rdynamic = true` keeps those symbols findable. /// /// Uses the same `writeIfDifferent` discipline as `stageLuaHeaders` so an /// unchanged `.so` keeps its on-disk mtime across reruns. fn stagePantoModule( allocator: Allocator, io: Io, layout: panto_home.Layout, ) !void { var dir = try Io.Dir.cwd().openDir(io, layout.lib_lua_dir, .{}); defer dir.close(io); try writeIfDifferent(allocator, io, dir, "panto.so", embedded_panto_so.bytes); } // --------------------------------------------------------------------------- // Stage the embedded `agent/` tree // --------------------------------------------------------------------------- /// Materialize the binary-embedded `agent/` tree under /// `$PANTO_HOME/agent/`. Each entry's `path` is relative to the agent /// root and may include subdirectories (`tools/read.lua` etc.); the /// parent directory is created lazily as we encounter files inside it. /// /// Uses the same `writeIfDifferent` discipline as `stageLuaHeaders`: /// unchanged files keep their on-disk mtime so downstream consumers /// (the extension loader's stat-cached searches, future-rocks builds /// against staged headers) don't see spurious invalidations. fn stageAgentTree( allocator: Allocator, io: Io, layout: panto_home.Layout, ) !void { var root = try Io.Dir.cwd().openDir(io, layout.agent_dir, .{}); defer root.close(io); for (embedded_agent.files) |entry| { // Ensure the file's parent directory exists. `path` is // forward-slash separated (the generator normalizes that), // so we can split off the dirname directly. if (std.fs.path.dirname(entry.path)) |parent| { root.createDirPath(io, parent) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; } try writeIfDifferent(allocator, io, root, entry.path, entry.contents); } } /// The default base `config.toml`, materialized at `$PANTO_HOME/config.toml` /// on first run if absent. It declares OpenAI and Anthropic providers whose /// API keys come from the conventional env vars — so a provider simply /// doesn't appear unless its key is exported. Edit or override at the user /// (`~/.config/panto/config.toml`) or project (`./.panto/config.toml`) layer. const default_base_config = \\# panto base config (auto-generated on first run). \\# \\# This is the lowest-precedence layer. Override anything here from \\# ~/.config/panto/config.toml (user) \\# ./.panto/config.toml (project) \\# Tables merge across layers; scalars and arrays from a higher layer \\# replace the lower one wholesale. \\# \\# A provider whose API key (or its api_key_env_var) is missing at \\# runtime is silently dropped — so these defaults disappear unless \\# you've exported the corresponding key. \\ \\[providers.openai] \\style = "openai_chat" \\base_url = "https://api.openai.com/v1" \\api_key_env_var = "OPENAI_API_KEY" \\ \\[providers.anthropic] \\style = "anthropic_messages" \\base_url = "https://api.anthropic.com" \\api_key_env_var = "ANTHROPIC_API_KEY" \\ \\# Pick the default model with `:`. The alias is \\# resolved against models.toml; an unknown alias is used verbatim as \\# the wire model name. \\# \\# [defaults] \\# model = "anthropic:claude-sonnet-4-20250514" \\ \\# Tool/extension availability (glob patterns). Empty allow = allow all. \\# [tools] \\# allow = ["std.*"] \\# deny = [] \\ ; /// Materialize the default base config at `$PANTO_HOME/config.toml`, /// but only if no file exists there yet. We never overwrite — the base /// config is user-editable, and a stale-but-edited file must win over the /// shipped default. fn stageDefaultConfig( allocator: Allocator, io: Io, layout: panto_home.Layout, ) !void { _ = allocator; var home = try Io.Dir.cwd().openDir(io, layout.home, .{}); defer home.close(io); home.access(io, "config.toml", .{}) catch |err| switch (err) { error.FileNotFound => { try home.writeFile(io, .{ .sub_path = "config.toml", .data = default_base_config }); return; }, else => return err, }; // Exists already — leave it untouched. } /// Write `contents` into `dir/name` only if the existing file differs. /// Creates the file if absent. fn writeIfDifferent( allocator: Allocator, io: Io, dir: Io.Dir, name: []const u8, contents: []const u8, ) !void { // Size the read limit to the new content (plus a small margin) so it // never truncates the comparison — staged files range from tiny // headers to a multi-megabyte `panto.so`. A larger on-disk file can't // match `contents` anyway, so capping at `contents.len + 1` is enough // to detect inequality without reading unbounded bytes. const limit = contents.len + 1; if (dir.readFileAlloc(io, name, allocator, .limited(limit))) |existing| { defer allocator.free(existing); if (std.mem.eql(u8, existing, contents)) return; } else |err| switch (err) { error.FileNotFound => {}, // A file larger than our limit definitionally differs from // `contents`; fall through and rewrite. error.StreamTooLong => {}, else => return err, } try dir.writeFile(io, .{ .sub_path = name, .data = contents }); } // --------------------------------------------------------------------------- // Step 3: write luarocks config // --------------------------------------------------------------------------- /// Write a tiny shell wrapper at `/bin/lua` that `exec`s /// ` lua "$@"`. luarocks invokes its configured `LUA` variable /// as a real executable when running rockspec build scripts; we can't /// give it `"panto lua"` directly because it splits argv naively, and /// we can't give it an absolute path to the panto binary because then /// it'd run panto's agent loop instead of `lua.c`'s REPL. /// /// The wrapper makes panto's lua subcommand visible to luarocks as if /// it were a standalone `lua` binary. Returns the wrapper path; caller /// frees. fn writeLuaWrapper( allocator: Allocator, io: Io, layout: panto_home.Layout, panto_executable_path: []const u8, ) ![]u8 { const bin_dir = try std.fs.path.join(allocator, &.{ layout.tree, "bin" }); defer allocator.free(bin_dir); Io.Dir.cwd().createDirPath(io, bin_dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; const wrapper_path = try std.fs.path.join(allocator, &.{ bin_dir, "lua" }); errdefer allocator.free(wrapper_path); var aw: std.Io.Writer.Allocating = .init(allocator); defer aw.deinit(); const w = &aw.writer; try w.writeAll("#!/bin/sh\n"); try w.writeAll("# Auto-generated by panto. Bridges luarocks's external\n"); try w.writeAll("# `lua` invocations to `panto lua`.\n"); try w.writeAll("exec "); try writeShellQuoted(w, panto_executable_path); try w.writeAll(" lua \"$@\"\n"); var bin = try Io.Dir.cwd().openDir(io, bin_dir, .{}); defer bin.close(io); try bin.writeFile(io, .{ .sub_path = "lua", .data = aw.written(), .flags = .{ .permissions = .executable_file }, }); return wrapper_path; } fn writeShellQuoted(w: anytype, s: []const u8) !void { try w.writeByte('\''); for (s) |ch| { if (ch == '\'') { try w.writeAll("'\\''"); } else { try w.writeByte(ch); } } try w.writeByte('\''); } /// Materialize a luarocks config-.lua under `layout.sysconfdir`. /// This is the file luarocks reads from `SYSCONFDIR`; we point every /// path-typed variable at the in-tree directories so installs land in /// `$PANTO_HOME/rocks/lua-X.Y.Z/`. /// /// Format reference: docs/config_file_format.md in the luarocks repo. fn writeLuarocksConfig( allocator: Allocator, io: Io, layout: panto_home.Layout, lua_wrapper_path: []const u8, ) !void { var aw: std.Io.Writer.Allocating = .init(allocator); defer aw.deinit(); const w = &aw.writer; try w.writeAll("-- Auto-generated by panto. Do not edit.\n"); try w.writeAll("-- This file is rewritten on every panto startup.\n\n"); try w.writeAll("rocks_trees = {\n { name = \"user\", root = "); try writeLuaString(w, layout.tree); try w.writeAll(" },\n}\n\n"); try w.writeAll("lua_interpreter = \"panto\"\n\n"); const bin_dir = std.fs.path.dirname(lua_wrapper_path) orelse layout.tree; try w.writeAll("variables = {\n"); try writeLuaKV(w, "LUA", lua_wrapper_path); try writeLuaKV(w, "LUA_BINDIR", bin_dir); try writeLuaKV(w, "LUA_INCDIR", layout.include_dir); try writeLuaKV(w, "LUA_LIBDIR", layout.lib_lua_dir); try writeLuaKV(w, "LUA_DIR", layout.tree); try writeLuaKV(w, "CURL", "curl"); try w.writeAll("}\n\n"); // We do not run luarocks's external `lua` invocation as a // separate Lua install; the panto binary itself answers to // `panto lua` and behaves like upstream lua.c. try w.writeAll("lua_version = "); try writeLuaString(w, manifest.lua_short_version); try w.writeAll("\n\n"); // Default deps mode: only consider the panto tree, ignore any // system-wide luarocks installations. Keeps reproducibility. try w.writeAll("deps_mode = \"one\"\n"); // Write atomically: stage as `.tmp`, rename into place. luarocks // reads the config eagerly on every invocation; an interrupted // write would corrupt the next startup. var sysconf = try Io.Dir.cwd().openDir(io, layout.sysconfdir, .{}); defer sysconf.close(io); const tmp_name = "config-staging.lua"; try sysconf.writeFile(io, .{ .sub_path = tmp_name, .data = aw.written() }); try sysconf.rename(tmp_name, sysconf, std.fs.path.basename(layout.config_file), io); } // --------------------------------------------------------------------------- // Step 4: embedded-source `package.searcher` // --------------------------------------------------------------------------- fn buildEmbeddedModuleMap(allocator: Allocator) !std.StringHashMapUnmanaged([]const u8) { var modules: std.StringHashMapUnmanaged([]const u8) = .empty; try modules.ensureTotalCapacity(allocator, @intCast(embedded_luarocks.files.len + 2)); for (embedded_luarocks.files) |entry| { modules.putAssumeCapacityNoClobber(entry.name, entry.contents); } return modules; } /// Process-singleton handle the C searcher uses to find module sources. /// Populated at bootstrap, read on every `require`. var module_map_singleton: ?*const std.StringHashMapUnmanaged([]const u8) = null; /// Install our embedded-source searcher as `package.searchers[2]`, /// after the preload searcher (slot 1) but before path-based searchers. /// Slot 2 is conventional for embedded code (luarocks's own all_in_one /// does the same). fn installEmbeddedSearcher( L: *c.lua_State, modules: *std.StringHashMapUnmanaged([]const u8), ) !void { module_map_singleton = modules; // Push package.searchers onto the stack. _ = c.lua_getglobal(L, "package"); if (c.lua_type(L, -1) != lua_bridge.T_TABLE) { c.lua_settop(L, c.lua_gettop(L) - 1); return BootstrapError.LuarocksSearcherInstallFailed; } _ = c.lua_getfield(L, -1, "searchers"); if (c.lua_type(L, -1) != lua_bridge.T_TABLE) { c.lua_settop(L, c.lua_gettop(L) - 2); return BootstrapError.LuarocksSearcherInstallFailed; } // We want to insert our searcher at slot 2 \u2014 push every existing // entry from slot 2 onward up by one, then assign our function. const n = c.lua_rawlen(L, -1); // Shift slots upward: searchers[i+1] = searchers[i] for i in [n..2]. var i: c.lua_Integer = @intCast(n); while (i >= 2) : (i -= 1) { _ = c.lua_rawgeti(L, -1, i); c.lua_rawseti(L, -2, i + 1); } c.lua_pushcfunction(L, embeddedSearcher); c.lua_rawseti(L, -2, 2); c.lua_settop(L, c.lua_gettop(L) - 2); // pop searchers + package } /// Lua C function: given a module name, look it up in the embedded map /// and return a loader closure if found; otherwise push an explanatory /// string and return 1 \u2014 standard `package.searchers` contract. fn embeddedSearcher(L: ?*c.lua_State) callconv(.c) c_int { const Lst = L.?; var name_len: usize = 0; const name_ptr = c.lua_tolstring(Lst, 1, &name_len); if (name_ptr == null) { _ = c.lua_pushlstring(Lst, "\n\t[panto: invalid require argument]", 35); return 1; } const name = name_ptr[0..name_len]; const map = module_map_singleton orelse { _ = c.lua_pushlstring(Lst, "\n\t[panto: embedded modules not initialized]", 43); return 1; }; // Resolve `name` first, then `name.init` if missing — matches // stock Lua path-searcher behavior where `require("foo")` checks // both `foo.lua` and `foo/init.lua`. var contents_opt = map.get(name); var resolved_name: []const u8 = name; var init_buf: [256]u8 = undefined; if (contents_opt == null) { const init_name = std.fmt.bufPrint(&init_buf, "{s}.init", .{name}) catch name; if (map.get(init_name)) |contents2| { contents_opt = contents2; resolved_name = init_name; } } if (contents_opt) |contents| { // Push loader: a Lua function that, when called, executes the // chunk and returns its result. `luaL_loadbufferx` with mode // "t" forbids binary chunks so we can't be tricked into loading // pre-compiled bytecode through this path. const chunkname_buf = blk: { // "=panto/" \u2014 the `=` prefix makes Lua print the name // verbatim in stack traces instead of prefixing with `[string]`. var sbuf: [256]u8 = undefined; const s = std.fmt.bufPrintZ(&sbuf, "=panto/{s}", .{resolved_name}) catch "=panto/embedded"; break :blk s; }; const status = c.luaL_loadbufferx( Lst, contents.ptr, contents.len, chunkname_buf.ptr, "t", ); if (status != c.LUA_OK) { // Error message left on stack by luaL_loadbufferx \u2014 return // it as the searcher's diagnostic. return 1; } return 1; } _ = c.lua_pushlstring(Lst, "\n\t[panto: no embedded match]", 27); return 1; } // --------------------------------------------------------------------------- // Step 5: inject luarocks.core.hardcoded // --------------------------------------------------------------------------- /// Construct a `luarocks.core.hardcoded` table with the runtime-resolved /// SYSCONFDIR (and FORCE_CONFIG = true, so luarocks doesn't go hunting /// for system configs) and stash it in `package.loaded` so that the /// later `require("luarocks.core.hardcoded")` returns our table instead /// of going to disk. This is exactly the trick the upstream GNUmakefile /// uses. fn injectHardcoded(L: *c.lua_State, layout: panto_home.Layout) !void { const snippet = \\local sysconfdir = ... \\package.loaded["luarocks.core.hardcoded"] = { \\ SYSCONFDIR = sysconfdir, \\ FORCE_CONFIG = true, \\} ; if (c.luaL_loadstring(L, snippet) != 0) { return BootstrapError.LuarocksInjectionFailed; } _ = c.lua_pushlstring(L, layout.sysconfdir.ptr, layout.sysconfdir.len); if (c.lua_pcallk(L, 1, 0, 0, 0, null) != 0) { return BootstrapError.LuarocksInjectionFailed; } } // --------------------------------------------------------------------------- // Step 6: package.path / package.cpath // --------------------------------------------------------------------------- /// Add `/share/lua//?.lua` and `/share/lua//?/init.lua` /// to `package.path`, and the matching `.so`/`.dylib` patterns to /// `package.cpath`, so rocks installed under our tree are visible to /// `require`. The original path entries follow ours \u2014 we shadow the /// system in case anything matches by accident. fn configurePackagePaths( allocator: Allocator, L: *c.lua_State, layout: panto_home.Layout, ) !void { const so_suffix = comptime soSuffix(); const path_segment = try std.fmt.allocPrint( allocator, "{0s}/?.lua;{0s}/?/init.lua", .{layout.share_lua_dir}, ); defer allocator.free(path_segment); const cpath_segment = try std.fmt.allocPrint( allocator, "{0s}/?{1s}", .{ layout.lib_lua_dir, so_suffix }, ); defer allocator.free(cpath_segment); // Snippet: prepend the segments to package.path/cpath. const snippet = \\local path_seg, cpath_seg = ... \\package.path = path_seg .. ";" .. package.path \\package.cpath = cpath_seg .. ";" .. package.cpath ; if (c.luaL_loadstring(L, snippet) != 0) { return BootstrapError.PathConfigFailed; } _ = c.lua_pushlstring(L, path_segment.ptr, path_segment.len); _ = c.lua_pushlstring(L, cpath_segment.ptr, cpath_segment.len); if (c.lua_pcallk(L, 2, 0, 0, 0, null) != 0) { return BootstrapError.PathConfigFailed; } } fn soSuffix() []const u8 { return switch (@import("builtin").os.tag) { .macos, .ios, .watchos, .tvos => ".so", .windows => ".dll", else => ".so", }; } /// Write `value` as a quoted Lua string. Uses the `"..."` short-string /// form, escaping backslashes, double-quotes, newlines, and any other /// control characters via `\NNN` decimal escapes. Suitable for any /// path on any platform. fn writeLuaString(w: anytype, value: []const u8) !void { try w.writeByte('"'); for (value) |ch| { switch (ch) { '\\' => try w.writeAll("\\\\"), '"' => try w.writeAll("\\\""), '\n' => try w.writeAll("\\n"), '\r' => try w.writeAll("\\r"), '\t' => try w.writeAll("\\t"), 0...8, 11, 12, 14...31, 127 => try w.print("\\{d}", .{ch}), else => try w.writeByte(ch), } } try w.writeByte('"'); } fn writeLuaKV(w: anytype, key: []const u8, value: []const u8) !void { try w.print(" {s} = ", .{key}); try writeLuaString(w, value); try w.writeAll(",\n"); } // --------------------------------------------------------------------------- // Step 7: reconcile manifest // --------------------------------------------------------------------------- /// For each pinned battery, check whether the version-stamped install /// metadata exists under `/lib/luarocks/rocks-///` /// (luarocks's standard metadata location). If absent, call /// `luarocks.cmd.run("install", name, version)` directly against our /// `lua_State` — no subprocess. /// /// Subsequent panto runs hit the fast path: the metadata directory /// exists, no luarocks invocation happens. fn reconcileBatteries( allocator: Allocator, io: Io, rt: *LuarocksRuntime, ) !void { var any_missing = false; for (manifest.batteries) |battery| { if (try batteryInstalled(allocator, io, rt.layout, battery)) continue; any_missing = true; break; } if (!any_missing) return; // Tell any child processes that go through panto (luarocks's // CMake/make subprocesses ultimately invoke `/bin/lua`, // which is our `panto lua` wrapper) to skip their own reconcile // step — we're already in the middle of it. // // Set process-wide via the C `setenv` so spawn calls inherit it. // Cleared after the loop so subsequent panto invocations of this // process see a clean environment. _ = c_setenv("PANTO_BOOTSTRAP_NO_RECONCILE", "1", 1); defer _ = c_unsetenv("PANTO_BOOTSTRAP_NO_RECONCILE"); for (manifest.batteries) |battery| { if (try batteryInstalled(allocator, io, rt.layout, battery)) continue; std.log.info( "panto: installing battery {s} {s} via luarocks (first run; this may take a minute)", .{ battery.name, battery.version }, ); try installBattery(allocator, rt.L, battery); } } extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; extern "c" fn unsetenv(name: [*:0]const u8) c_int; const c_setenv = setenv; const c_unsetenv = unsetenv; /// Check whether `/lib/luarocks/rocks-///` /// exists. luarocks creates this directory atomically as part of an /// install, so its presence is a reliable signal that the rock landed. fn batteryInstalled( allocator: Allocator, io: Io, layout: panto_home.Layout, battery: manifest.Battery, ) !bool { const subpath = try std.fs.path.join( allocator, &.{ layout.rocks_metadata_dir, battery.name, battery.version }, ); defer allocator.free(subpath); var dir = Io.Dir.cwd().openDir(io, subpath, .{}) catch |err| switch (err) { error.FileNotFound, error.NotDir => return false, else => return err, }; dir.close(io); return true; } /// Spawn ` lua -e 'require("luarocks.cmd").run("install", name, version)'` /// inheriting stdout/stderr so the user sees compilation output. /// /// We set `PANTO_BOOTSTRAP_NO_RECONCILE=1` in the child so that the /// nested `panto lua` doesn't itself try to reconcile (which would /// recurse forever — installing luv would re-trigger installing luv). /// The child still runs the full filesystem-side bootstrap (searcher, /// hardcoded, package paths) — only the manifest reconcile is skipped. /// Run luarocks's `install` command directly against our `lua_State`. /// /// We mirror what `src/bin/luarocks` does — it's a thin Lua driver /// that builds a command table and calls `cmd.run_command(...)`. We /// embed the driver's source (`embedded_luarocks.luarocks_main`) and /// load it as a chunk, passing `{"install", name, version}` as the /// vararg the chunk reads via `...`. /// /// luarocks's `run_command` prints diagnostics to stdout/stderr as /// it goes, so the user sees compilation progress in real time. /// On failure it calls `os.exit(1)`, which our `pcall` wrapper /// intercepts before it can terminate the process. fn installBattery( allocator: Allocator, L: *c.lua_State, battery: manifest.Battery, ) !void { // We wrap the embedded driver in a function that overrides // `os.exit` for the duration of the call. luarocks's `die()` // ultimately calls `os.exit(1)`, which would terminate panto // entirely; we want to catch the failure as a regular error. const driver = embedded_luarocks.luarocks_main; const wrapper = \\local orig_exit = os.exit \\local args = { ... } \\local fail_code = nil \\os.exit = function(code) fail_code = code or 0; error({ panto_exit = code or 0 }) end \\arg = arg or {} \\arg[0] = arg[0] or "luarocks" \\local driver = args[1] \\local chunk, err = load(driver, "=panto/luarocks-driver", "t") \\if not chunk then error("failed to load luarocks driver: " .. tostring(err)) end \\local ok, err = pcall(chunk, table.unpack(args, 2)) \\os.exit = orig_exit \\if not ok then \\ if type(err) == "table" and err.panto_exit ~= nil then \\ if err.panto_exit ~= 0 then \\ error("luarocks exited with code " .. tostring(err.panto_exit)) \\ end \\ else \\ error(err) \\ end \\end ; if (c.luaL_loadstring(L, wrapper) != 0) { return BootstrapError.LuarocksInstallFailed; } // Strip the shebang line if present. `luaL_loadfile` does this // for files, but we're going through `load(...)` from Lua which // doesn't — it'll choke on `#!/usr/bin/env lua` as a syntax error. var driver_slice: []const u8 = driver; if (driver_slice.len >= 2 and driver_slice[0] == '#' and driver_slice[1] == '!') { if (std.mem.indexOfScalar(u8, driver_slice, '\n')) |nl| { driver_slice = driver_slice[nl + 1 ..]; } } // Pass the driver source as the first arg, then the luarocks // CLI args. We use `lua_pushlstring` to push everything as // Lua strings (no NUL terminator needed, since lua_pushlstring // takes an explicit length). _ = c.lua_pushlstring(L, driver_slice.ptr, driver_slice.len); _ = c.lua_pushlstring(L, "install", 7); _ = c.lua_pushlstring(L, battery.name.ptr, battery.name.len); _ = c.lua_pushlstring(L, battery.version.ptr, battery.version.len); _ = allocator; // currently unused; reserved for future scratch needs if (c.lua_pcallk(L, 4, 0, 0, 0, null) != 0) { var len: usize = 0; const msg = c.lua_tolstring(L, -1, &len); if (msg != null) { std.log.err( "panto: luarocks install of {s} {s} failed: {s}", .{ battery.name, battery.version, msg[0..len] }, ); } c.lua_settop(L, c.lua_gettop(L) - 1); return BootstrapError.LuarocksInstallFailed; } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- const testing = std.testing; test "embedded module map contains luarocks.core.cfg, luarocks.cmd, compat53.init" { var modules = try buildEmbeddedModuleMap(testing.allocator); defer modules.deinit(testing.allocator); try testing.expect(modules.get("luarocks.core.cfg") != null); try testing.expect(modules.get("luarocks.cmd") != null); // `luarocks.cmd.init` is a real submodule (the `init` subcommand); // luarocks.cmd is the module itself — distinct entries. try testing.expect(modules.get("luarocks.cmd.init") != null); try testing.expect(modules.get("compat53.init") != null); try testing.expect(modules.get("compat53.module") != null); // Not there: try testing.expect(modules.get("luarocks.nonexistent") == null); } test "embedded searcher fallback resolves bare name via name.init" { // Mirrors stock Lua searcher semantics: require("compat53") should // succeed even though our map only stores `compat53.init`. We test // the lookup logic directly here — the C-level installation is // exercised by integration runs of the panto binary. var modules = try buildEmbeddedModuleMap(testing.allocator); defer modules.deinit(testing.allocator); const bare = modules.get("compat53"); try testing.expect(bare == null); const via_init = modules.get("compat53.init"); try testing.expect(via_init != null); }