| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609 |
- -- 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
|