|
|
@@ -0,0 +1,532 @@
|
|
|
+-- 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": "...", "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.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)
|
|
|
+-- ============================================================================
|
|
|
+
|
|
|
+-- 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
|
|
|
+
|
|
|
+ -- Resolve URL from registry if needed
|
|
|
+ if not url and M.registry[name] then
|
|
|
+ url = M.registry[name].url
|
|
|
+ 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)
|
|
|
+
|
|
|
+ -- Clone the repository
|
|
|
+ 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
|
|
|
+
|
|
|
+-- 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
|
|
|
+
|
|
|
+ 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
|