535 lines
14 KiB
Plaintext
535 lines
14 KiB
Plaintext
// Module shop system for managing dependencies and mods
|
|
|
|
var toml = use('toml')
|
|
var json = use('json')
|
|
var fd = use('fd')
|
|
var utf8 = use('utf8')
|
|
var http = use('http')
|
|
var miniz = use('miniz')
|
|
var time = use('time')
|
|
|
|
var Shop = {}
|
|
|
|
var shop_path = '.cell/cell.toml'
|
|
var lock_path = '.cell/lock.toml'
|
|
|
|
function slurpwrite(path, content) {
|
|
var f = fd.open(path)
|
|
fd.write(f, content)
|
|
fd.close(f)
|
|
}
|
|
|
|
function ensure_dir(path) {
|
|
if (fd.stat(path).isDirectory) return true
|
|
|
|
var parts = path.split('/')
|
|
var current = ''
|
|
for (var i = 0; i < parts.length; i++) {
|
|
if (parts[i] == '') continue
|
|
current += parts[i] + '/'
|
|
if (!fd.stat(current).isDirectory) {
|
|
fd.mkdir(current)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Load cell.toml configuration
|
|
Shop.load_config = function() {
|
|
if (!fd.stat(shop_path).isFile)
|
|
return null
|
|
|
|
var content = utf8.decode(fd.slurp(shop_path))
|
|
return toml.decode(content)
|
|
}
|
|
|
|
// Save cell.toml configuration
|
|
Shop.save_config = function(config) {
|
|
slurpwrite(shop_path, toml.encode(config));
|
|
}
|
|
|
|
// Load lock.toml configuration
|
|
Shop.load_lock = function() {
|
|
if (!fd.stat(lock_path).isFile)
|
|
return {}
|
|
|
|
var content = utf8.decode(fd.slurp(lock_path))
|
|
return toml.decode(content) || {}
|
|
}
|
|
|
|
// Save lock.toml configuration
|
|
Shop.save_lock = function(lock) {
|
|
slurpwrite(lock_path, toml.encode(lock));
|
|
}
|
|
|
|
// Initialize .cell directory structure
|
|
Shop.init = function() {
|
|
if (!fd.stat('.cell').isDirectory) {
|
|
fd.mkdir('.cell')
|
|
}
|
|
|
|
if (!fd.stat('.cell/modules').isDirectory) {
|
|
fd.mkdir('.cell/modules')
|
|
}
|
|
|
|
if (!fd.stat('.cell/build').isDirectory) {
|
|
fd.mkdir('.cell/build')
|
|
}
|
|
|
|
if (!fd.stat('.cell/patches').isDirectory) {
|
|
fd.mkdir('.cell/patches')
|
|
}
|
|
|
|
if (!fd.stat('.cell/lock.toml').isFile) {
|
|
slurpwrite('.cell/lock.toml', '# Lock file for module integrity\n');
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Parse module locator (e.g., "git.world/jj/mod@v0.6.3")
|
|
Shop.parse_locator = function(locator) {
|
|
var parts = locator.split('@')
|
|
if (parts.length != 2) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
path: parts[0],
|
|
version: parts[1],
|
|
name: parts[0].split('/').pop()
|
|
}
|
|
}
|
|
|
|
// Convert module locator to download URL
|
|
Shop.get_download_url = function(locator) {
|
|
var parsed = Shop.parse_locator(locator)
|
|
if (!parsed) return null
|
|
|
|
// Handle different git hosting patterns
|
|
if (locator.startsWith('https://')) {
|
|
// Remove https:// prefix for parsing
|
|
var cleanLocator = locator.substring(8)
|
|
var hostAndPath = cleanLocator.split('@')[0]
|
|
|
|
// Gitea pattern: gitea.pockle.world/user/repo@branch
|
|
if (hostAndPath.includes('gitea.')) {
|
|
return 'https://' + hostAndPath + '/archive/' + parsed.version + '.zip'
|
|
}
|
|
|
|
// GitHub pattern: github.com/user/repo@tag
|
|
if (hostAndPath.includes('github.com')) {
|
|
return 'https://' + hostAndPath + '/archive/refs/tags/' + parsed.version + '.zip'
|
|
}
|
|
|
|
// GitLab pattern: gitlab.com/user/repo@tag
|
|
if (hostAndPath.includes('gitlab.')) {
|
|
return 'https://' + hostAndPath + '/-/archive/' + parsed.version + '/' + parsed.name + '-' + parsed.version + '.zip'
|
|
}
|
|
} else {
|
|
// Implicit https
|
|
var hostAndPath = parsed.path
|
|
// Gitea pattern: gitea.pockle.world/user/repo@branch
|
|
if (hostAndPath.includes('gitea.')) {
|
|
return 'https://' + hostAndPath + '/archive/' + parsed.version + '.zip'
|
|
}
|
|
|
|
// GitHub pattern: github.com/user/repo@tag
|
|
if (hostAndPath.includes('github.com')) {
|
|
return 'https://' + hostAndPath + '/archive/refs/tags/' + parsed.version + '.zip'
|
|
}
|
|
}
|
|
|
|
// Fallback to original locator if no pattern matches
|
|
return locator
|
|
}
|
|
|
|
// Add a dependency
|
|
Shop.add_dependency = function(alias, locator) {
|
|
var config = Shop.load_config()
|
|
if (!config) {
|
|
log.error("No cell.toml found")
|
|
return false
|
|
}
|
|
|
|
if (!config.dependencies) {
|
|
config.dependencies = {}
|
|
}
|
|
|
|
config.dependencies[alias] = locator
|
|
Shop.save_config(config)
|
|
return true
|
|
}
|
|
|
|
// Remove a dependency
|
|
Shop.remove_dependency = function(alias) {
|
|
var config = Shop.load_config()
|
|
if (!config) {
|
|
log.error("No cell.toml found")
|
|
return false
|
|
}
|
|
|
|
if (!config.dependencies || !config.dependencies[alias]) {
|
|
return false
|
|
}
|
|
|
|
delete config.dependencies[alias]
|
|
Shop.save_config(config)
|
|
return true
|
|
}
|
|
|
|
// Get the API URL for checking remote git commits
|
|
Shop.get_api_url = function(locator) {
|
|
var parsed = Shop.parse_locator(locator)
|
|
if (!parsed) return null
|
|
|
|
var hostAndPath = parsed.path
|
|
if (locator.startsWith('https://')) {
|
|
hostAndPath = locator.substring(8).split('@')[0]
|
|
}
|
|
|
|
var parts = hostAndPath.split('/')
|
|
|
|
// Gitea pattern: gitea.pockle.world/user/repo@branch
|
|
if (hostAndPath.includes('gitea.')) {
|
|
var host = parts[0]
|
|
var user = parts[1]
|
|
var repo = parts[2]
|
|
return 'https://' + host + '/api/v1/repos/' + user + '/' + repo + '/branches/' + parsed.version
|
|
}
|
|
|
|
// GitHub pattern: github.com/user/repo@tag or @branch
|
|
if (hostAndPath.includes('github.com')) {
|
|
var user = parts[1]
|
|
var repo = parts[2]
|
|
// Try branch first, then tag
|
|
return 'https://api.github.com/repos/' + user + '/' + repo + '/branches/' + parsed.version
|
|
}
|
|
|
|
// GitLab pattern: gitlab.com/user/repo@tag
|
|
if (hostAndPath.includes('gitlab.')) {
|
|
var user = parts[1]
|
|
var repo = parts[2]
|
|
var projectId = encodeURIComponent(user + '/' + repo)
|
|
return 'https://' + parts[0] + '/api/v4/projects/' + projectId + '/repository/branches/' + parsed.version
|
|
}
|
|
|
|
// Fallback - return null if no API pattern matches
|
|
return null
|
|
}
|
|
|
|
// Extract commit hash from API response
|
|
Shop.extract_commit_hash = function(locator, response) {
|
|
if (!response) return null
|
|
|
|
var data
|
|
try {
|
|
data = json.decode(response)
|
|
} catch (e) {
|
|
log.console("Failed to parse API response: " + e)
|
|
return null
|
|
}
|
|
|
|
// Handle different git hosting response formats
|
|
if (locator.includes('gitea.')) {
|
|
// Gitea: response.commit.id
|
|
return data.commit && data.commit.id
|
|
} else if (locator.includes('github.com')) {
|
|
// GitHub: response.commit.sha
|
|
return data.commit && data.commit.sha
|
|
} else if (locator.includes('gitlab.')) {
|
|
// GitLab: response.commit.id
|
|
return data.commit && data.commit.id
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
// Get the module directory for a given alias
|
|
Shop.get_module_dir = function(alias) {
|
|
var config = Shop.load_config()
|
|
if (!config || !config.dependencies || !config.dependencies[alias]) {
|
|
return null
|
|
}
|
|
|
|
// Check if replaced
|
|
if (config.replace && config.replace[alias]) {
|
|
return config.replace[alias]
|
|
}
|
|
|
|
var locator = config.dependencies[alias]
|
|
var parsed = Shop.parse_locator(locator)
|
|
if (!parsed) return null
|
|
|
|
return '.cell/modules/' + parsed.path
|
|
}
|
|
|
|
// Install a dependency
|
|
Shop.install = function(alias) {
|
|
var config = Shop.load_config()
|
|
if (!config || !config.dependencies || !config.dependencies[alias]) {
|
|
log.error("Dependency not found in config: " + alias)
|
|
return false
|
|
}
|
|
|
|
var locator = config.dependencies[alias]
|
|
var parsed = Shop.parse_locator(locator)
|
|
var target_dir = '.cell/modules/' + parsed.path
|
|
|
|
log.console("Installing " + alias + " (" + locator + ")...")
|
|
|
|
// 1. Get Commit Hash
|
|
var api_url = Shop.get_api_url(locator)
|
|
var commit_hash = null
|
|
if (api_url) {
|
|
try {
|
|
log.console("Fetching info from " + api_url)
|
|
var resp = http.fetch(api_url)
|
|
var resp_text = utf8.decode(resp)
|
|
commit_hash = Shop.extract_commit_hash(locator, resp_text)
|
|
log.console("Resolved commit: " + commit_hash)
|
|
} catch (e) {
|
|
log.console("Warning: Failed to fetch API info: " + e)
|
|
}
|
|
}
|
|
|
|
// 2. Download Zip
|
|
var download_url = Shop.get_download_url(locator)
|
|
if (!download_url) {
|
|
log.error("Could not determine download URL for " + locator)
|
|
return false
|
|
}
|
|
|
|
log.console("Downloading from " + download_url)
|
|
var zip_blob
|
|
try {
|
|
zip_blob = http.fetch(download_url)
|
|
} catch (e) {
|
|
log.error("Download failed: " + e)
|
|
return false
|
|
}
|
|
|
|
// 3. Unpack
|
|
log.console("Unpacking to " + target_dir)
|
|
ensure_dir(target_dir)
|
|
|
|
var zip = miniz.read(zip_blob)
|
|
if (!zip) {
|
|
log.error("Failed to read zip archive")
|
|
return false
|
|
}
|
|
|
|
var count = zip.count()
|
|
for (var i = 0; i < count; i++) {
|
|
if (zip.is_directory(i)) continue
|
|
|
|
var filename = zip.get_filename(i)
|
|
// Strip top-level directory
|
|
var parts = filename.split('/')
|
|
if (parts.length > 1) {
|
|
parts.shift() // Remove root folder
|
|
var rel_path = parts.join('/')
|
|
|
|
var full_path = target_dir + '/' + rel_path
|
|
var dir_path = full_path.substring(0, full_path.lastIndexOf('/'))
|
|
ensure_dir(dir_path)
|
|
|
|
var content = zip.slurp(filename)
|
|
slurpwrite(full_path, content)
|
|
}
|
|
}
|
|
|
|
// 4. Update Lock
|
|
if (commit_hash) {
|
|
var lock = Shop.load_lock()
|
|
lock[alias] = {
|
|
locator: locator,
|
|
commit: commit_hash,
|
|
updated: time.number()
|
|
}
|
|
Shop.save_lock(lock)
|
|
}
|
|
|
|
log.console("Installed " + alias)
|
|
return true
|
|
}
|
|
|
|
// Verify dependencies
|
|
Shop.verify = function() {
|
|
var config = Shop.load_config()
|
|
if (!config || !config.dependencies) return true
|
|
|
|
var all_ok = true
|
|
for (var alias in config.dependencies) {
|
|
var dir = Shop.get_module_dir(alias)
|
|
if (!dir) {
|
|
// Might be a replace that is invalid or something else
|
|
continue
|
|
}
|
|
if (!fd.stat(dir).isDirectory) {
|
|
log.error("Missing dependency: " + alias + " (expected at " + dir + ")")
|
|
all_ok = false
|
|
} else {
|
|
// Check if empty?
|
|
}
|
|
}
|
|
return all_ok
|
|
}
|
|
|
|
// Check for updates
|
|
Shop.update = function() {
|
|
var config = Shop.load_config()
|
|
if (!config || !config.dependencies) return
|
|
|
|
var lock = Shop.load_lock()
|
|
|
|
for (var alias in config.dependencies) {
|
|
var locator = config.dependencies[alias]
|
|
var api_url = Shop.get_api_url(locator)
|
|
|
|
if (api_url) {
|
|
try {
|
|
var resp = http.fetch(api_url)
|
|
var resp_text = utf8.decode(resp)
|
|
var remote_hash = Shop.extract_commit_hash(locator, resp_text)
|
|
|
|
var local_hash = lock[alias] ? lock[alias].commit : null
|
|
|
|
if (remote_hash && remote_hash != local_hash) {
|
|
log.console("Update available for " + alias + ": " + local_hash + " -> " + remote_hash)
|
|
Shop.install(alias)
|
|
} else {
|
|
log.console(alias + " is up to date.")
|
|
}
|
|
} catch (e) {
|
|
log.error("Failed to check update for " + alias + ": " + e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compile a module
|
|
Shop.compile_module = function(alias) {
|
|
var module_dir = Shop.get_module_dir(alias)
|
|
if (!module_dir) {
|
|
log.error("Module not found: " + alias)
|
|
return false
|
|
}
|
|
|
|
log.console("Would compile module: " + alias + " from " + module_dir)
|
|
return true
|
|
}
|
|
|
|
// Build all modules
|
|
Shop.build = function() {
|
|
var config = Shop.load_config()
|
|
if (!config || !config.dependencies) {
|
|
return true
|
|
}
|
|
|
|
for (var alias in config.dependencies) {
|
|
Shop.compile_module(alias)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Get all declared dependencies as a map of alias -> locator
|
|
Shop.get_dependencies = function() {
|
|
var config = Shop.load_config()
|
|
if (!config || !config.dependencies) {
|
|
return {}
|
|
}
|
|
return config.dependencies
|
|
}
|
|
|
|
// Resolve a module path given a package context
|
|
// Returns { path, package_name } or null if not found
|
|
// Resolution order:
|
|
// 1. Local to the current package (if package_name is set)
|
|
// 2. Declared dependencies (by alias)
|
|
// 3. Core modules (handled by caller)
|
|
Shop.resolve_module = function(module_name, package_name, is_file_fn) {
|
|
var config = Shop.load_config()
|
|
var dependencies = (config && config.dependencies) || {}
|
|
|
|
// If we're in a package context, check the package first
|
|
if (package_name) {
|
|
var pkg_path = '.cell/modules/' + package_name + '/' + module_name + '.cm'
|
|
if (is_file_fn(pkg_path)) {
|
|
return { path: pkg_path, package_name: package_name }
|
|
}
|
|
}
|
|
|
|
// Check if module_name contains a slash (explicit package reference)
|
|
if (module_name.includes('/')) {
|
|
var parts = module_name.split('/')
|
|
var pkg_alias = parts[0]
|
|
var sub_module = parts.slice(1).join('/')
|
|
|
|
// Check if it's a declared dependency
|
|
if (dependencies[pkg_alias]) {
|
|
// Need to resolve alias to canonical path
|
|
var locator = dependencies[pkg_alias]
|
|
var parsed = Shop.parse_locator(locator)
|
|
var canonical_path = parsed.path
|
|
|
|
var dep_path = '.cell/modules/' + canonical_path + '/' + sub_module + '.cm'
|
|
if (is_file_fn(dep_path)) {
|
|
return { path: dep_path, package_name: pkg_alias }
|
|
}
|
|
}
|
|
|
|
// Check local path (relative to project root)
|
|
var local_path = module_name + '.cm'
|
|
if (is_file_fn(local_path)) {
|
|
return { path: local_path, package_name: null }
|
|
}
|
|
} else {
|
|
// Simple module name - check local first, then dependencies
|
|
var local_path = module_name + '.cm'
|
|
if (is_file_fn(local_path)) {
|
|
return { path: local_path, package_name: null }
|
|
}
|
|
|
|
// Check each declared dependency for this module
|
|
for (var alias in dependencies) {
|
|
var locator = dependencies[alias]
|
|
var parsed = Shop.parse_locator(locator)
|
|
var canonical_path = parsed.path
|
|
|
|
var dep_path = '.cell/modules/' + canonical_path + '/' + module_name + '.cm'
|
|
if (is_file_fn(dep_path)) {
|
|
return { path: dep_path, package_name: alias }
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
// Get the package name from a file path
|
|
// e.g., '.cell/modules/extramath/spline.cm' -> 'extramath'
|
|
// e.g., 'myfile.cm' -> null
|
|
Shop.get_package_from_path = function(path) {
|
|
if (!path) return null
|
|
var modules_prefix = '.cell/modules/'
|
|
if (path.startsWith(modules_prefix)) {
|
|
var rest = path.substring(modules_prefix.length)
|
|
// This logic is tricky with nested paths like gitea.pockle.world/john/prosperon
|
|
// We probably need to reverse map from path to alias using config
|
|
var config = Shop.load_config()
|
|
if (config && config.dependencies) {
|
|
for (var alias in config.dependencies) {
|
|
var locator = config.dependencies[alias]
|
|
var parsed = Shop.parse_locator(locator)
|
|
if (rest.startsWith(parsed.path + '/')) {
|
|
return alias
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
return Shop |