package.lua 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. -- package.lua
  2. -- Lumacs Package Manager
  3. -- ============================================================================
  4. -- Provides package installation, loading, and management similar to MELPA/lazy.nvim
  5. --
  6. -- Usage in packages.lua:
  7. -- return {
  8. -- "which-key", -- Short name (from registry)
  9. -- { "evil-mode", lazy = true }, -- Lazy loaded
  10. -- { url = "https://github.com/user/pkg" }, -- Git URL
  11. -- { dir = "~/my-package" }, -- Local directory
  12. -- }
  13. -- ============================================================================
  14. local M = {}
  15. -- Package manager state
  16. M.packages = {} -- Loaded packages: name -> module
  17. M.package_specs = {} -- Package specifications from packages.lua
  18. M.loaded = {} -- Track which packages are loaded
  19. M.errors = {} -- Track loading errors
  20. -- Configuration
  21. M.config = {
  22. -- Where user packages are installed
  23. install_path = os.getenv("HOME") .. "/.lumacs/packages",
  24. -- Where built-in packages live (in lumacs repo)
  25. builtin_path = "lua/packages",
  26. -- Bundled registry (shipped with lumacs)
  27. bundled_registry = "lua/core/registry.json",
  28. -- Registry URL (for short name resolution)
  29. registry_url = "https://raw.githubusercontent.com/lumacs/registry/main/registry.json",
  30. -- Local registry cache
  31. registry_cache = os.getenv("HOME") .. "/.lumacs/registry.json",
  32. -- Auto-install missing packages
  33. auto_install = true,
  34. -- Check for updates on startup
  35. check_updates = false,
  36. }
  37. -- Registry cache (populated from registry.json)
  38. M.registry = {}
  39. -- ============================================================================
  40. -- Utility Functions
  41. -- ============================================================================
  42. -- Check if a file exists
  43. local function file_exists(path)
  44. local f = io.open(path, "r")
  45. if f then
  46. f:close()
  47. return true
  48. end
  49. return false
  50. end
  51. -- Check if a directory exists
  52. local function dir_exists(path)
  53. -- Use Lua's ability to open directory as file (returns nil for dirs on most systems)
  54. -- This is a simple heuristic; we'll improve with C++ integration later
  55. local f = io.open(path .. "/.", "r")
  56. if f then
  57. f:close()
  58. return true
  59. end
  60. -- Try another approach - check for init.lua or package.lua
  61. return file_exists(path .. "/init.lua") or file_exists(path .. "/package.lua")
  62. end
  63. -- Expand ~ to home directory
  64. local function expand_path(path)
  65. if path:sub(1, 1) == "~" then
  66. return os.getenv("HOME") .. path:sub(2)
  67. end
  68. return path
  69. end
  70. -- Read entire file
  71. local function read_file(path)
  72. local f = io.open(path, "r")
  73. if not f then return nil end
  74. local content = f:read("*a")
  75. f:close()
  76. return content
  77. end
  78. -- Write file
  79. local function write_file(path, content)
  80. local f = io.open(path, "w")
  81. if not f then return false end
  82. f:write(content)
  83. f:close()
  84. return true
  85. end
  86. -- Simple JSON parser for registry (basic implementation)
  87. local function parse_json_simple(str)
  88. -- Very basic JSON object parser for our simple registry format
  89. -- Format: { "name": { "url": "...", "description": "..." }, ... }
  90. local result = {}
  91. -- Remove outer braces and whitespace
  92. str = str:match("^%s*{(.*)}")
  93. if not str then return result end
  94. -- Match each package entry
  95. for name, content in str:gmatch('"([^"]+)"%s*:%s*({[^}]*})') do
  96. local entry = {}
  97. entry.url = content:match('"url"%s*:%s*"([^"]*)"')
  98. entry.description = content:match('"description"%s*:%s*"([^"]*)"')
  99. entry.version = content:match('"version"%s*:%s*"([^"]*)"')
  100. result[name] = entry
  101. end
  102. return result
  103. end
  104. -- ============================================================================
  105. -- Package Specification Handling
  106. -- ============================================================================
  107. -- Normalize a package spec to standard format
  108. local function normalize_spec(spec)
  109. if type(spec) == "string" then
  110. -- Simple string: package name
  111. return {
  112. name = spec,
  113. source = "name",
  114. }
  115. elseif type(spec) == "table" then
  116. local normalized = {}
  117. -- Handle array-style: { "name", options... }
  118. if spec[1] then
  119. normalized.name = spec[1]
  120. normalized.source = "name"
  121. end
  122. -- Handle explicit fields
  123. if spec.url then
  124. normalized.url = spec.url
  125. normalized.source = "git"
  126. normalized.name = normalized.name or spec.url:match("([^/]+)%.git$") or spec.url:match("([^/]+)$")
  127. end
  128. if spec.dir then
  129. normalized.dir = expand_path(spec.dir)
  130. normalized.source = "local"
  131. normalized.name = normalized.name or spec.dir:match("([^/]+)$")
  132. end
  133. -- Copy other options
  134. normalized.lazy = spec.lazy
  135. normalized.config = spec.config
  136. normalized.dependencies = spec.dependencies
  137. normalized.branch = spec.branch
  138. normalized.tag = spec.tag
  139. normalized.commit = spec.commit
  140. normalized.enabled = spec.enabled ~= false -- Default to enabled
  141. return normalized
  142. end
  143. return nil
  144. end
  145. -- ============================================================================
  146. -- Package Loading
  147. -- ============================================================================
  148. -- Find package path (checks builtin, then installed)
  149. local function find_package_path(name)
  150. -- Check builtin packages first
  151. local builtin = M.config.builtin_path .. "/" .. name .. ".lua"
  152. if file_exists(builtin) then
  153. return builtin, "builtin"
  154. end
  155. -- Check builtin as directory
  156. local builtin_dir = M.config.builtin_path .. "/" .. name
  157. if file_exists(builtin_dir .. "/init.lua") then
  158. return builtin_dir .. "/init.lua", "builtin"
  159. end
  160. -- Check installed packages
  161. local installed = M.config.install_path .. "/" .. name
  162. if file_exists(installed .. "/init.lua") then
  163. return installed .. "/init.lua", "installed"
  164. end
  165. -- Check for single-file installed package
  166. local installed_file = M.config.install_path .. "/" .. name .. ".lua"
  167. if file_exists(installed_file) then
  168. return installed_file, "installed"
  169. end
  170. return nil, nil
  171. end
  172. -- Load a single package
  173. function M.load_package(spec)
  174. local normalized = normalize_spec(spec)
  175. if not normalized then
  176. return false, "Invalid package specification"
  177. end
  178. if not normalized.enabled then
  179. return true, "Package disabled"
  180. end
  181. local name = normalized.name
  182. -- Already loaded?
  183. if M.loaded[name] then
  184. return true, "Already loaded"
  185. end
  186. -- Find the package
  187. local path, source
  188. if normalized.source == "local" then
  189. -- Local directory
  190. if file_exists(normalized.dir .. "/init.lua") then
  191. path = normalized.dir .. "/init.lua"
  192. source = "local"
  193. elseif file_exists(normalized.dir .. ".lua") then
  194. path = normalized.dir .. ".lua"
  195. source = "local"
  196. end
  197. else
  198. path, source = find_package_path(name)
  199. end
  200. if not path then
  201. -- Package not found - might need installation
  202. return false, "Package not found: " .. name
  203. end
  204. -- Load the package
  205. local ok, result = pcall(dofile, path)
  206. if not ok then
  207. M.errors[name] = result
  208. return false, "Error loading " .. name .. ": " .. tostring(result)
  209. end
  210. -- Store the package module
  211. M.packages[name] = result
  212. M.loaded[name] = {
  213. path = path,
  214. source = source,
  215. spec = normalized,
  216. }
  217. -- Run config function if provided
  218. if normalized.config and type(normalized.config) == "function" then
  219. local config_ok, config_err = pcall(normalized.config)
  220. if not config_ok then
  221. print("[package] Warning: config error for " .. name .. ": " .. tostring(config_err))
  222. end
  223. end
  224. return true, "Loaded from " .. source
  225. end
  226. -- Load all packages from specs
  227. function M.load_all()
  228. local loaded_count = 0
  229. local error_count = 0
  230. for _, spec in ipairs(M.package_specs) do
  231. local normalized = normalize_spec(spec)
  232. if normalized and normalized.enabled and not normalized.lazy then
  233. local ok, msg = M.load_package(spec)
  234. if ok then
  235. loaded_count = loaded_count + 1
  236. else
  237. error_count = error_count + 1
  238. print("[package] " .. msg)
  239. end
  240. end
  241. end
  242. return loaded_count, error_count
  243. end
  244. -- ============================================================================
  245. -- Package Installation (Git-based)
  246. -- ============================================================================
  247. -- Install a package from git
  248. function M.install(spec)
  249. local normalized = normalize_spec(spec)
  250. if not normalized then
  251. return false, "Invalid package specification"
  252. end
  253. local name = normalized.name
  254. local url = normalized.url
  255. -- Resolve URL from registry if needed
  256. if not url and M.registry[name] then
  257. url = M.registry[name].url
  258. end
  259. if not url then
  260. return false, "No URL found for package: " .. name
  261. end
  262. -- Create install directory
  263. local install_dir = M.config.install_path .. "/" .. name
  264. -- Check if already installed
  265. if dir_exists(install_dir) then
  266. return true, "Already installed"
  267. end
  268. -- Ensure parent directory exists
  269. os.execute("mkdir -p " .. M.config.install_path)
  270. -- Clone the repository
  271. local branch_opt = ""
  272. if normalized.branch then
  273. branch_opt = " -b " .. normalized.branch
  274. end
  275. local cmd = string.format("git clone --depth 1%s %s %s 2>&1", branch_opt, url, install_dir)
  276. local handle = io.popen(cmd)
  277. local result = handle:read("*a")
  278. local success = handle:close()
  279. if success then
  280. return true, "Installed " .. name
  281. else
  282. return false, "Failed to install " .. name .. ": " .. result
  283. end
  284. end
  285. -- Update a package
  286. function M.update(name)
  287. local install_dir = M.config.install_path .. "/" .. name
  288. if not dir_exists(install_dir) then
  289. return false, "Package not installed: " .. name
  290. end
  291. local cmd = string.format("cd %s && git pull 2>&1", install_dir)
  292. local handle = io.popen(cmd)
  293. local result = handle:read("*a")
  294. local success = handle:close()
  295. if success then
  296. return true, result
  297. else
  298. return false, "Failed to update " .. name .. ": " .. result
  299. end
  300. end
  301. -- Remove a package
  302. function M.remove(name)
  303. local install_dir = M.config.install_path .. "/" .. name
  304. if not dir_exists(install_dir) then
  305. return false, "Package not installed: " .. name
  306. end
  307. local cmd = string.format("rm -rf %s 2>&1", install_dir)
  308. local success = os.execute(cmd)
  309. if success then
  310. M.packages[name] = nil
  311. M.loaded[name] = nil
  312. return true, "Removed " .. name
  313. else
  314. return false, "Failed to remove " .. name
  315. end
  316. end
  317. -- ============================================================================
  318. -- Registry Management
  319. -- ============================================================================
  320. -- Load registry from cache or bundled
  321. function M.load_registry()
  322. -- Try user cache first
  323. local content = read_file(M.config.registry_cache)
  324. if content then
  325. M.registry = parse_json_simple(content)
  326. return true
  327. end
  328. -- Fall back to bundled registry
  329. content = read_file(M.config.bundled_registry)
  330. if content then
  331. M.registry = parse_json_simple(content)
  332. return true
  333. end
  334. return false
  335. end
  336. -- Update registry from remote
  337. function M.update_registry()
  338. -- This would use curl/wget - for now we'll use a Lua HTTP library or shell
  339. local cmd = string.format("curl -s %s -o %s 2>&1", M.config.registry_url, M.config.registry_cache)
  340. local success = os.execute(cmd)
  341. if success then
  342. return M.load_registry()
  343. end
  344. return false
  345. end
  346. -- ============================================================================
  347. -- Setup and Initialization
  348. -- ============================================================================
  349. -- Setup the package manager
  350. function M.setup(opts)
  351. opts = opts or {}
  352. -- Merge config
  353. for k, v in pairs(opts) do
  354. M.config[k] = v
  355. end
  356. -- Ensure directories exist
  357. os.execute("mkdir -p " .. M.config.install_path)
  358. -- Load registry
  359. M.load_registry()
  360. -- Load package specs if packages.lua exists
  361. local packages_file = os.getenv("HOME") .. "/.lumacs/packages.lua"
  362. if file_exists(packages_file) then
  363. local ok, specs = pcall(dofile, packages_file)
  364. if ok and type(specs) == "table" then
  365. M.package_specs = specs
  366. end
  367. end
  368. -- Auto-install missing packages
  369. if M.config.auto_install then
  370. for _, spec in ipairs(M.package_specs) do
  371. local normalized = normalize_spec(spec)
  372. if normalized and normalized.enabled and normalized.source == "name" then
  373. local path = find_package_path(normalized.name)
  374. if not path and M.registry[normalized.name] then
  375. print("[package] Auto-installing " .. normalized.name .. "...")
  376. M.install(spec)
  377. end
  378. end
  379. end
  380. end
  381. return M
  382. end
  383. -- Get package info
  384. function M.info(name)
  385. local info = {
  386. name = name,
  387. loaded = M.loaded[name] ~= nil,
  388. installed = false,
  389. builtin = false,
  390. registry = M.registry[name],
  391. }
  392. local path, source = find_package_path(name)
  393. if path then
  394. info.installed = true
  395. info.path = path
  396. info.source = source
  397. info.builtin = (source == "builtin")
  398. end
  399. if M.loaded[name] then
  400. info.load_info = M.loaded[name]
  401. end
  402. return info
  403. end
  404. -- List all available packages
  405. function M.list_available()
  406. local packages = {}
  407. -- Builtin packages
  408. local builtin_dir = M.config.builtin_path
  409. -- Note: We'd need to enumerate directory - for now, rely on what's loaded
  410. -- Registry packages
  411. for name, entry in pairs(M.registry) do
  412. packages[name] = {
  413. name = name,
  414. source = "registry",
  415. description = entry.description,
  416. url = entry.url,
  417. }
  418. end
  419. -- Loaded packages
  420. for name, info in pairs(M.loaded) do
  421. packages[name] = packages[name] or {}
  422. packages[name].name = name
  423. packages[name].loaded = true
  424. packages[name].source = info.source
  425. packages[name].path = info.path
  426. end
  427. return packages
  428. end
  429. -- List loaded packages
  430. function M.list_loaded()
  431. local result = {}
  432. for name, info in pairs(M.loaded) do
  433. table.insert(result, {
  434. name = name,
  435. source = info.source,
  436. path = info.path,
  437. })
  438. end
  439. table.sort(result, function(a, b) return a.name < b.name end)
  440. return result
  441. end
  442. -- ============================================================================
  443. -- Module Export
  444. -- ============================================================================
  445. return M