-- package.lua -- Lumacs Package Manager -- ============================================================================ -- Provides package installation, loading, and management similar to MELPA/lazy.nvim -- -- Usage in packages.lua: -- return { -- "which-key", -- Short name (from registry) -- { "evil-mode", lazy = true }, -- Lazy loaded -- { url = "https://github.com/user/pkg" }, -- Git URL -- { dir = "~/my-package" }, -- Local directory -- } -- ============================================================================ local M = {} -- Package manager state M.packages = {} -- Loaded packages: name -> module M.package_specs = {} -- Package specifications from packages.lua M.loaded = {} -- Track which packages are loaded M.errors = {} -- Track loading errors -- Configuration M.config = { -- Where user packages are installed install_path = os.getenv("HOME") .. "/.lumacs/packages", -- Where built-in packages live (in lumacs repo) builtin_path = "lua/packages", -- Bundled registry (shipped with lumacs) bundled_registry = "lua/core/registry.json", -- Registry URL (for short name resolution) registry_url = "https://raw.githubusercontent.com/lumacs/registry/main/registry.json", -- Local registry cache registry_cache = os.getenv("HOME") .. "/.lumacs/registry.json", -- Auto-install missing packages auto_install = true, -- Check for updates on startup check_updates = false, } -- Registry cache (populated from registry.json) M.registry = {} -- ============================================================================ -- Utility Functions -- ============================================================================ -- Check if a file exists local function file_exists(path) local f = io.open(path, "r") if f then f:close() return true end return false end -- Check if a directory exists local function dir_exists(path) -- Use Lua's ability to open directory as file (returns nil for dirs on most systems) -- This is a simple heuristic; we'll improve with C++ integration later local f = io.open(path .. "/.", "r") if f then f:close() return true end -- Try another approach - check for init.lua or package.lua return file_exists(path .. "/init.lua") or file_exists(path .. "/package.lua") end -- Expand ~ to home directory local function expand_path(path) if path:sub(1, 1) == "~" then return os.getenv("HOME") .. path:sub(2) end return path end -- Read entire file local function read_file(path) local f = io.open(path, "r") if not f then return nil end local content = f:read("*a") f:close() return content end -- Write file local function write_file(path, content) local f = io.open(path, "w") if not f then return false end f:write(content) f:close() return true end -- Simple JSON parser for registry (basic implementation) local function parse_json_simple(str) -- Very basic JSON object parser for our simple registry format -- Format: { "name": { "url": "...", "path": "...", "description": "..." }, ... } local result = {} -- Remove outer braces and whitespace str = str:match("^%s*{(.*)}") if not str then return result end -- Match each package entry for name, content in str:gmatch('"([^"]+)"%s*:%s*({[^}]*})') do local entry = {} entry.url = content:match('"url"%s*:%s*"([^"]*)"') entry.path = content:match('"path"%s*:%s*"([^"]*)"') -- Subpath in monorepo entry.description = content:match('"description"%s*:%s*"([^"]*)"') entry.version = content:match('"version"%s*:%s*"([^"]*)"') result[name] = entry end return result end -- ============================================================================ -- Package Specification Handling -- ============================================================================ -- Normalize a package spec to standard format local function normalize_spec(spec) if type(spec) == "string" then -- Simple string: package name return { name = spec, source = "name", } elseif type(spec) == "table" then local normalized = {} -- Handle array-style: { "name", options... } if spec[1] then normalized.name = spec[1] normalized.source = "name" end -- Handle explicit fields if spec.url then normalized.url = spec.url normalized.source = "git" normalized.name = normalized.name or spec.url:match("([^/]+)%.git$") or spec.url:match("([^/]+)$") end if spec.dir then normalized.dir = expand_path(spec.dir) normalized.source = "local" normalized.name = normalized.name or spec.dir:match("([^/]+)$") end -- Copy other options normalized.lazy = spec.lazy normalized.config = spec.config normalized.dependencies = spec.dependencies normalized.branch = spec.branch normalized.tag = spec.tag normalized.commit = spec.commit normalized.enabled = spec.enabled ~= false -- Default to enabled return normalized end return nil end -- ============================================================================ -- Package Loading -- ============================================================================ -- Find package path (checks builtin, then installed) local function find_package_path(name) -- Check builtin packages first local builtin = M.config.builtin_path .. "/" .. name .. ".lua" if file_exists(builtin) then return builtin, "builtin" end -- Check builtin as directory local builtin_dir = M.config.builtin_path .. "/" .. name if file_exists(builtin_dir .. "/init.lua") then return builtin_dir .. "/init.lua", "builtin" end -- Check installed packages local installed = M.config.install_path .. "/" .. name if file_exists(installed .. "/init.lua") then return installed .. "/init.lua", "installed" end -- Check for single-file installed package local installed_file = M.config.install_path .. "/" .. name .. ".lua" if file_exists(installed_file) then return installed_file, "installed" end return nil, nil end -- Load a single package function M.load_package(spec) local normalized = normalize_spec(spec) if not normalized then return false, "Invalid package specification" end if not normalized.enabled then return true, "Package disabled" end local name = normalized.name -- Already loaded? if M.loaded[name] then return true, "Already loaded" end -- Find the package local path, source if normalized.source == "local" then -- Local directory if file_exists(normalized.dir .. "/init.lua") then path = normalized.dir .. "/init.lua" source = "local" elseif file_exists(normalized.dir .. ".lua") then path = normalized.dir .. ".lua" source = "local" end else path, source = find_package_path(name) end if not path then -- Package not found - might need installation return false, "Package not found: " .. name end -- Load the package local ok, result = pcall(dofile, path) if not ok then M.errors[name] = result return false, "Error loading " .. name .. ": " .. tostring(result) end -- Store the package module M.packages[name] = result M.loaded[name] = { path = path, source = source, spec = normalized, } -- Run config function if provided if normalized.config and type(normalized.config) == "function" then local config_ok, config_err = pcall(normalized.config) if not config_ok then print("[package] Warning: config error for " .. name .. ": " .. tostring(config_err)) end end return true, "Loaded from " .. source end -- Load all packages from specs function M.load_all() local loaded_count = 0 local error_count = 0 for _, spec in ipairs(M.package_specs) do local normalized = normalize_spec(spec) if normalized and normalized.enabled and not normalized.lazy then local ok, msg = M.load_package(spec) if ok then loaded_count = loaded_count + 1 else error_count = error_count + 1 print("[package] " .. msg) end end end return loaded_count, error_count end -- ============================================================================ -- Package Installation (Git-based) -- ============================================================================ -- Monorepo cache directory M.monorepo_cache = os.getenv("HOME") .. "/.lumacs/repos" -- Install a package from git function M.install(spec) local normalized = normalize_spec(spec) if not normalized then return false, "Invalid package specification" end local name = normalized.name local url = normalized.url local subpath = nil -- For monorepo packages -- Resolve URL and path from registry if needed if not url and M.registry[name] then url = M.registry[name].url subpath = M.registry[name].path end if not url then return false, "No URL found for package: " .. name end -- Create install directory local install_dir = M.config.install_path .. "/" .. name -- Check if already installed if dir_exists(install_dir) then return true, "Already installed" end -- Ensure parent directory exists os.execute("mkdir -p " .. M.config.install_path) if subpath then -- Monorepo installation: clone to cache, then copy subpath local repo_name = url:match("([^/]+)$") or "repo" local cache_dir = M.monorepo_cache .. "/" .. repo_name -- Clone or update the monorepo cache if not dir_exists(cache_dir) then os.execute("mkdir -p " .. M.monorepo_cache) local branch_opt = "" if normalized.branch then branch_opt = " -b " .. normalized.branch end local clone_cmd = string.format("git clone --depth 1%s %s %s 2>&1", branch_opt, url, cache_dir) local handle = io.popen(clone_cmd) local result = handle:read("*a") local success = handle:close() if not success then return false, "Failed to clone monorepo: " .. result end end -- Copy the specific package directory local src_dir = cache_dir .. "/" .. subpath if not dir_exists(src_dir) then return false, "Package path not found in monorepo: " .. subpath end local copy_cmd = string.format("cp -r %s %s 2>&1", src_dir, install_dir) local handle = io.popen(copy_cmd) local result = handle:read("*a") local success = handle:close() if success then return true, "Installed " .. name .. " (from monorepo)" else return false, "Failed to copy package: " .. result end else -- Standard single-repo installation local branch_opt = "" if normalized.branch then branch_opt = " -b " .. normalized.branch end local cmd = string.format("git clone --depth 1%s %s %s 2>&1", branch_opt, url, install_dir) local handle = io.popen(cmd) local result = handle:read("*a") local success = handle:close() if success then return true, "Installed " .. name else return false, "Failed to install " .. name .. ": " .. result end end end -- Update a package function M.update(name) local install_dir = M.config.install_path .. "/" .. name if not dir_exists(install_dir) then return false, "Package not installed: " .. name end -- Check if this is a monorepo package if M.registry[name] and M.registry[name].path then local subpath = M.registry[name].path local url = M.registry[name].url local repo_name = url:match("([^/]+)$") or "repo" local cache_dir = M.monorepo_cache .. "/" .. repo_name -- Update the monorepo cache if dir_exists(cache_dir) then local pull_cmd = string.format("cd %s && git pull 2>&1", cache_dir) local handle = io.popen(pull_cmd) handle:read("*a") handle:close() end -- Re-copy the package local src_dir = cache_dir .. "/" .. subpath if dir_exists(src_dir) then os.execute(string.format("rm -rf %s", install_dir)) local copy_cmd = string.format("cp -r %s %s 2>&1", src_dir, install_dir) local handle = io.popen(copy_cmd) local result = handle:read("*a") local success = handle:close() if success then return true, "Updated " .. name .. " (from monorepo)" else return false, "Failed to update " .. name .. ": " .. result end end end -- Standard git pull for single-repo packages local cmd = string.format("cd %s && git pull 2>&1", install_dir) local handle = io.popen(cmd) local result = handle:read("*a") local success = handle:close() if success then return true, result else return false, "Failed to update " .. name .. ": " .. result end end -- Remove a package function M.remove(name) local install_dir = M.config.install_path .. "/" .. name if not dir_exists(install_dir) then return false, "Package not installed: " .. name end local cmd = string.format("rm -rf %s 2>&1", install_dir) local success = os.execute(cmd) if success then M.packages[name] = nil M.loaded[name] = nil return true, "Removed " .. name else return false, "Failed to remove " .. name end end -- ============================================================================ -- Registry Management -- ============================================================================ -- Load registry from cache or bundled function M.load_registry() -- Try user cache first local content = read_file(M.config.registry_cache) if content then M.registry = parse_json_simple(content) return true end -- Fall back to bundled registry content = read_file(M.config.bundled_registry) if content then M.registry = parse_json_simple(content) return true end return false end -- Update registry from remote function M.update_registry() -- This would use curl/wget - for now we'll use a Lua HTTP library or shell local cmd = string.format("curl -s %s -o %s 2>&1", M.config.registry_url, M.config.registry_cache) local success = os.execute(cmd) if success then return M.load_registry() end return false end -- ============================================================================ -- Setup and Initialization -- ============================================================================ -- Setup the package manager function M.setup(opts) opts = opts or {} -- Merge config for k, v in pairs(opts) do M.config[k] = v end -- Ensure directories exist os.execute("mkdir -p " .. M.config.install_path) -- Load registry M.load_registry() -- Load package specs if packages.lua exists local packages_file = os.getenv("HOME") .. "/.lumacs/packages.lua" if file_exists(packages_file) then local ok, specs = pcall(dofile, packages_file) if ok and type(specs) == "table" then M.package_specs = specs end end -- Auto-install missing packages if M.config.auto_install then for _, spec in ipairs(M.package_specs) do local normalized = normalize_spec(spec) if normalized and normalized.enabled and normalized.source == "name" then local path = find_package_path(normalized.name) if not path and M.registry[normalized.name] then print("[package] Auto-installing " .. normalized.name .. "...") M.install(spec) end end end end return M end -- Get package info function M.info(name) local info = { name = name, loaded = M.loaded[name] ~= nil, installed = false, builtin = false, registry = M.registry[name], } local path, source = find_package_path(name) if path then info.installed = true info.path = path info.source = source info.builtin = (source == "builtin") end if M.loaded[name] then info.load_info = M.loaded[name] end return info end -- List all available packages function M.list_available() local packages = {} -- Builtin packages local builtin_dir = M.config.builtin_path -- Note: We'd need to enumerate directory - for now, rely on what's loaded -- Registry packages for name, entry in pairs(M.registry) do packages[name] = { name = name, source = "registry", description = entry.description, url = entry.url, } end -- Loaded packages for name, info in pairs(M.loaded) do packages[name] = packages[name] or {} packages[name].name = name packages[name].loaded = true packages[name].source = info.source packages[name].path = info.path end return packages end -- List loaded packages function M.list_loaded() local result = {} for name, info in pairs(M.loaded) do table.insert(result, { name = name, source = info.source, path = info.path, }) end table.sort(result, function(a, b) return a.name < b.name end) return result end -- ============================================================================ -- Module Export -- ============================================================================ return M