package.lua 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609
  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": "...", "path": "...", "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.path = content:match('"path"%s*:%s*"([^"]*)"') -- Subpath in monorepo
  99. entry.description = content:match('"description"%s*:%s*"([^"]*)"')
  100. entry.version = content:match('"version"%s*:%s*"([^"]*)"')
  101. result[name] = entry
  102. end
  103. return result
  104. end
  105. -- ============================================================================
  106. -- Package Specification Handling
  107. -- ============================================================================
  108. -- Normalize a package spec to standard format
  109. local function normalize_spec(spec)
  110. if type(spec) == "string" then
  111. -- Simple string: package name
  112. return {
  113. name = spec,
  114. source = "name",
  115. }
  116. elseif type(spec) == "table" then
  117. local normalized = {}
  118. -- Handle array-style: { "name", options... }
  119. if spec[1] then
  120. normalized.name = spec[1]
  121. normalized.source = "name"
  122. end
  123. -- Handle explicit fields
  124. if spec.url then
  125. normalized.url = spec.url
  126. normalized.source = "git"
  127. normalized.name = normalized.name or spec.url:match("([^/]+)%.git$") or spec.url:match("([^/]+)$")
  128. end
  129. if spec.dir then
  130. normalized.dir = expand_path(spec.dir)
  131. normalized.source = "local"
  132. normalized.name = normalized.name or spec.dir:match("([^/]+)$")
  133. end
  134. -- Copy other options
  135. normalized.lazy = spec.lazy
  136. normalized.config = spec.config
  137. normalized.dependencies = spec.dependencies
  138. normalized.branch = spec.branch
  139. normalized.tag = spec.tag
  140. normalized.commit = spec.commit
  141. normalized.enabled = spec.enabled ~= false -- Default to enabled
  142. return normalized
  143. end
  144. return nil
  145. end
  146. -- ============================================================================
  147. -- Package Loading
  148. -- ============================================================================
  149. -- Find package path (checks builtin, then installed)
  150. local function find_package_path(name)
  151. -- Check builtin packages first
  152. local builtin = M.config.builtin_path .. "/" .. name .. ".lua"
  153. if file_exists(builtin) then
  154. return builtin, "builtin"
  155. end
  156. -- Check builtin as directory
  157. local builtin_dir = M.config.builtin_path .. "/" .. name
  158. if file_exists(builtin_dir .. "/init.lua") then
  159. return builtin_dir .. "/init.lua", "builtin"
  160. end
  161. -- Check installed packages
  162. local installed = M.config.install_path .. "/" .. name
  163. if file_exists(installed .. "/init.lua") then
  164. return installed .. "/init.lua", "installed"
  165. end
  166. -- Check for single-file installed package
  167. local installed_file = M.config.install_path .. "/" .. name .. ".lua"
  168. if file_exists(installed_file) then
  169. return installed_file, "installed"
  170. end
  171. return nil, nil
  172. end
  173. -- Load a single package
  174. function M.load_package(spec)
  175. local normalized = normalize_spec(spec)
  176. if not normalized then
  177. return false, "Invalid package specification"
  178. end
  179. if not normalized.enabled then
  180. return true, "Package disabled"
  181. end
  182. local name = normalized.name
  183. -- Already loaded?
  184. if M.loaded[name] then
  185. return true, "Already loaded"
  186. end
  187. -- Find the package
  188. local path, source
  189. if normalized.source == "local" then
  190. -- Local directory
  191. if file_exists(normalized.dir .. "/init.lua") then
  192. path = normalized.dir .. "/init.lua"
  193. source = "local"
  194. elseif file_exists(normalized.dir .. ".lua") then
  195. path = normalized.dir .. ".lua"
  196. source = "local"
  197. end
  198. else
  199. path, source = find_package_path(name)
  200. end
  201. if not path then
  202. -- Package not found - might need installation
  203. return false, "Package not found: " .. name
  204. end
  205. -- Load the package
  206. local ok, result = pcall(dofile, path)
  207. if not ok then
  208. M.errors[name] = result
  209. return false, "Error loading " .. name .. ": " .. tostring(result)
  210. end
  211. -- Store the package module
  212. M.packages[name] = result
  213. M.loaded[name] = {
  214. path = path,
  215. source = source,
  216. spec = normalized,
  217. }
  218. -- Run config function if provided
  219. if normalized.config and type(normalized.config) == "function" then
  220. local config_ok, config_err = pcall(normalized.config)
  221. if not config_ok then
  222. print("[package] Warning: config error for " .. name .. ": " .. tostring(config_err))
  223. end
  224. end
  225. return true, "Loaded from " .. source
  226. end
  227. -- Load all packages from specs
  228. function M.load_all()
  229. local loaded_count = 0
  230. local error_count = 0
  231. for _, spec in ipairs(M.package_specs) do
  232. local normalized = normalize_spec(spec)
  233. if normalized and normalized.enabled and not normalized.lazy then
  234. local ok, msg = M.load_package(spec)
  235. if ok then
  236. loaded_count = loaded_count + 1
  237. else
  238. error_count = error_count + 1
  239. print("[package] " .. msg)
  240. end
  241. end
  242. end
  243. return loaded_count, error_count
  244. end
  245. -- ============================================================================
  246. -- Package Installation (Git-based)
  247. -- ============================================================================
  248. -- Monorepo cache directory
  249. M.monorepo_cache = os.getenv("HOME") .. "/.lumacs/repos"
  250. -- Install a package from git
  251. function M.install(spec)
  252. local normalized = normalize_spec(spec)
  253. if not normalized then
  254. return false, "Invalid package specification"
  255. end
  256. local name = normalized.name
  257. local url = normalized.url
  258. local subpath = nil -- For monorepo packages
  259. -- Resolve URL and path from registry if needed
  260. if not url and M.registry[name] then
  261. url = M.registry[name].url
  262. subpath = M.registry[name].path
  263. end
  264. if not url then
  265. return false, "No URL found for package: " .. name
  266. end
  267. -- Create install directory
  268. local install_dir = M.config.install_path .. "/" .. name
  269. -- Check if already installed
  270. if dir_exists(install_dir) then
  271. return true, "Already installed"
  272. end
  273. -- Ensure parent directory exists
  274. os.execute("mkdir -p " .. M.config.install_path)
  275. if subpath then
  276. -- Monorepo installation: clone to cache, then copy subpath
  277. local repo_name = url:match("([^/]+)$") or "repo"
  278. local cache_dir = M.monorepo_cache .. "/" .. repo_name
  279. -- Clone or update the monorepo cache
  280. if not dir_exists(cache_dir) then
  281. os.execute("mkdir -p " .. M.monorepo_cache)
  282. local branch_opt = ""
  283. if normalized.branch then
  284. branch_opt = " -b " .. normalized.branch
  285. end
  286. local clone_cmd = string.format("git clone --depth 1%s %s %s 2>&1", branch_opt, url, cache_dir)
  287. local handle = io.popen(clone_cmd)
  288. local result = handle:read("*a")
  289. local success = handle:close()
  290. if not success then
  291. return false, "Failed to clone monorepo: " .. result
  292. end
  293. end
  294. -- Copy the specific package directory
  295. local src_dir = cache_dir .. "/" .. subpath
  296. if not dir_exists(src_dir) then
  297. return false, "Package path not found in monorepo: " .. subpath
  298. end
  299. local copy_cmd = string.format("cp -r %s %s 2>&1", src_dir, install_dir)
  300. local handle = io.popen(copy_cmd)
  301. local result = handle:read("*a")
  302. local success = handle:close()
  303. if success then
  304. return true, "Installed " .. name .. " (from monorepo)"
  305. else
  306. return false, "Failed to copy package: " .. result
  307. end
  308. else
  309. -- Standard single-repo installation
  310. local branch_opt = ""
  311. if normalized.branch then
  312. branch_opt = " -b " .. normalized.branch
  313. end
  314. local cmd = string.format("git clone --depth 1%s %s %s 2>&1", branch_opt, url, install_dir)
  315. local handle = io.popen(cmd)
  316. local result = handle:read("*a")
  317. local success = handle:close()
  318. if success then
  319. return true, "Installed " .. name
  320. else
  321. return false, "Failed to install " .. name .. ": " .. result
  322. end
  323. end
  324. end
  325. -- Update a package
  326. function M.update(name)
  327. local install_dir = M.config.install_path .. "/" .. name
  328. if not dir_exists(install_dir) then
  329. return false, "Package not installed: " .. name
  330. end
  331. -- Check if this is a monorepo package
  332. if M.registry[name] and M.registry[name].path then
  333. local subpath = M.registry[name].path
  334. local url = M.registry[name].url
  335. local repo_name = url:match("([^/]+)$") or "repo"
  336. local cache_dir = M.monorepo_cache .. "/" .. repo_name
  337. -- Update the monorepo cache
  338. if dir_exists(cache_dir) then
  339. local pull_cmd = string.format("cd %s && git pull 2>&1", cache_dir)
  340. local handle = io.popen(pull_cmd)
  341. handle:read("*a")
  342. handle:close()
  343. end
  344. -- Re-copy the package
  345. local src_dir = cache_dir .. "/" .. subpath
  346. if dir_exists(src_dir) then
  347. os.execute(string.format("rm -rf %s", install_dir))
  348. local copy_cmd = string.format("cp -r %s %s 2>&1", src_dir, install_dir)
  349. local handle = io.popen(copy_cmd)
  350. local result = handle:read("*a")
  351. local success = handle:close()
  352. if success then
  353. return true, "Updated " .. name .. " (from monorepo)"
  354. else
  355. return false, "Failed to update " .. name .. ": " .. result
  356. end
  357. end
  358. end
  359. -- Standard git pull for single-repo packages
  360. local cmd = string.format("cd %s && git pull 2>&1", install_dir)
  361. local handle = io.popen(cmd)
  362. local result = handle:read("*a")
  363. local success = handle:close()
  364. if success then
  365. return true, result
  366. else
  367. return false, "Failed to update " .. name .. ": " .. result
  368. end
  369. end
  370. -- Remove a package
  371. function M.remove(name)
  372. local install_dir = M.config.install_path .. "/" .. name
  373. if not dir_exists(install_dir) then
  374. return false, "Package not installed: " .. name
  375. end
  376. local cmd = string.format("rm -rf %s 2>&1", install_dir)
  377. local success = os.execute(cmd)
  378. if success then
  379. M.packages[name] = nil
  380. M.loaded[name] = nil
  381. return true, "Removed " .. name
  382. else
  383. return false, "Failed to remove " .. name
  384. end
  385. end
  386. -- ============================================================================
  387. -- Registry Management
  388. -- ============================================================================
  389. -- Load registry from cache or bundled
  390. function M.load_registry()
  391. -- Try user cache first
  392. local content = read_file(M.config.registry_cache)
  393. if content then
  394. M.registry = parse_json_simple(content)
  395. return true
  396. end
  397. -- Fall back to bundled registry
  398. content = read_file(M.config.bundled_registry)
  399. if content then
  400. M.registry = parse_json_simple(content)
  401. return true
  402. end
  403. return false
  404. end
  405. -- Update registry from remote
  406. function M.update_registry()
  407. -- This would use curl/wget - for now we'll use a Lua HTTP library or shell
  408. local cmd = string.format("curl -s %s -o %s 2>&1", M.config.registry_url, M.config.registry_cache)
  409. local success = os.execute(cmd)
  410. if success then
  411. return M.load_registry()
  412. end
  413. return false
  414. end
  415. -- ============================================================================
  416. -- Setup and Initialization
  417. -- ============================================================================
  418. -- Setup the package manager
  419. function M.setup(opts)
  420. opts = opts or {}
  421. -- Merge config
  422. for k, v in pairs(opts) do
  423. M.config[k] = v
  424. end
  425. -- Ensure directories exist
  426. os.execute("mkdir -p " .. M.config.install_path)
  427. -- Load registry
  428. M.load_registry()
  429. -- Load package specs if packages.lua exists
  430. local packages_file = os.getenv("HOME") .. "/.lumacs/packages.lua"
  431. if file_exists(packages_file) then
  432. local ok, specs = pcall(dofile, packages_file)
  433. if ok and type(specs) == "table" then
  434. M.package_specs = specs
  435. end
  436. end
  437. -- Auto-install missing packages
  438. if M.config.auto_install then
  439. for _, spec in ipairs(M.package_specs) do
  440. local normalized = normalize_spec(spec)
  441. if normalized and normalized.enabled and normalized.source == "name" then
  442. local path = find_package_path(normalized.name)
  443. if not path and M.registry[normalized.name] then
  444. print("[package] Auto-installing " .. normalized.name .. "...")
  445. M.install(spec)
  446. end
  447. end
  448. end
  449. end
  450. return M
  451. end
  452. -- Get package info
  453. function M.info(name)
  454. local info = {
  455. name = name,
  456. loaded = M.loaded[name] ~= nil,
  457. installed = false,
  458. builtin = false,
  459. registry = M.registry[name],
  460. }
  461. local path, source = find_package_path(name)
  462. if path then
  463. info.installed = true
  464. info.path = path
  465. info.source = source
  466. info.builtin = (source == "builtin")
  467. end
  468. if M.loaded[name] then
  469. info.load_info = M.loaded[name]
  470. end
  471. return info
  472. end
  473. -- List all available packages
  474. function M.list_available()
  475. local packages = {}
  476. -- Builtin packages
  477. local builtin_dir = M.config.builtin_path
  478. -- Note: We'd need to enumerate directory - for now, rely on what's loaded
  479. -- Registry packages
  480. for name, entry in pairs(M.registry) do
  481. packages[name] = {
  482. name = name,
  483. source = "registry",
  484. description = entry.description,
  485. url = entry.url,
  486. }
  487. end
  488. -- Loaded packages
  489. for name, info in pairs(M.loaded) do
  490. packages[name] = packages[name] or {}
  491. packages[name].name = name
  492. packages[name].loaded = true
  493. packages[name].source = info.source
  494. packages[name].path = info.path
  495. end
  496. return packages
  497. end
  498. -- List loaded packages
  499. function M.list_loaded()
  500. local result = {}
  501. for name, info in pairs(M.loaded) do
  502. table.insert(result, {
  503. name = name,
  504. source = info.source,
  505. path = info.path,
  506. })
  507. end
  508. table.sort(result, function(a, b) return a.name < b.name end)
  509. return result
  510. end
  511. -- ============================================================================
  512. -- Module Export
  513. -- ============================================================================
  514. return M