Files
cell/scripts/shop.cm
2025-12-03 06:41:18 -06:00

744 lines
19 KiB
Plaintext

// Module shop system for managing dependencies and mods
var toml = use('toml')
var json = use('json')
var fd = use('fd')
var http = use('http')
var miniz = use('miniz')
var time = use('time')
var js = use('js')
var qop
var core_qop
// a locator is what is used to import a module, like prosperon/sprite
// in prosperon/sprite, sprite is the module, and prosperon is the package (usually, an alias)
// a canonical package name relates prosperon to its source, like gitea.pockle.world/john/prosperon
var Shop = {}
var SCOPE_LOCAL = 0
var SCOPE_PACKAGE = 1
var SCOPE_CORE = 2
var MOD_EXT = '.cm'
var ACTOR_EXT = '.ce'
var os
var use_cache
Shop.set_os = function(o)
{
os = o
qop = os.load_internal('js_qop_use')
core_qop = os.core_qop
use_cache = os.use_cache
}
var config = null
var shop_path = '.cell/cell.toml'
var lock_path = '.cell/lock.toml'
var open_dl = {}
function get_import_package(name) {
var parts = name.split('/')
if (parts.length > 1)
return parts[0]
return null
}
function get_import_name(path)
{
var parts = path.split('/')
if (parts.length < 2) return null
return parts.slice(1).join('/')
}
// given a path, get a full package import
// ie, 'prosperon/sprite' would return 'gitea.pockle.world/john/prosperon/sprite'
// if prosperon were a dependency
function get_package_from_path(path, ctx)
{
var pkg = get_import_package(path)
var locator = get_import_name(path)
if (!pkg) return null
var pck = get_normalized_module(pkg, ctx)
return pck + "/" + locator
}
function get_normalized_package(path, ctx)
{
var pkg = get_import_package(path)
if (!pkg) return null
return get_normalized_module(pkg, ctx)
}
// taking the package into account, find the canonical name
function get_normalized_module(mod, ctx) {
var deps = Shop.load_config(ctx);
return deps.dependencies[mod]
}
function get_import_dl(name) {
var pkg = get_import_package(name)
if (!pkg) return null
if (open_dl[pkg]) return open_dl[pkg]
var dlpath = `.cell/modules/${pkg}/${pkg}${dylib_ext}`
var dl = os.dylib_open(dlpath)
if (dl) {
open_dl[pkg] = dl
return dl
}
return null
}
Shop.get_c_symbol = function get_c_symbol(name) {
var dl = get_import_dl(name)
var symname = `js_${name.replace('/', '_')}_use`
if (dl)
return os.dylib_symbol(dl, symname)
else
return os.load_internal(symname)
}
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
// module given in canonical format (e.g., "gitea.pockle.world/john/prosperon")
// If module is null, loads the root cell.toml
// If module is provided, loads module/cell.toml
Shop.load_config = function(module) {
var content
if (!module) {
if (!fd.is_file(shop_path))
return null
content = fd.slurp(shop_path)
} else {
var module_path = `.cell/modules/${module}/cell.toml`
if (!fd.stat(module_path).isFile)
return null
content = fd.slurp(module_path)
}
return toml.decode(text(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 = text(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 = text(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
}
var open_dls = {}
function get_locator_module_path(locator)
{
var canon_pkg = get_package_from_path(locator);
var path = `.cell/modules/${canon_pkg}`;
if (fd.is_directory(path))
return path;
return null
}
function resolve_locator(path, ext, ctx)
{
var deps = Shop.load_config(ctx).dependencies || {}
var local_path
if (ctx)
local_path = `.cell/modules/${ctx}/${path}${ext}`
else
local_path = path + ext
if (fd.is_file(local_path))
return {path: local_path, scope: SCOPE_LOCAL, script: text(fd.slurp(local_path))};
var mod_path = `.cell/modules/${get_package_from_path(path, ctx)}${ext}`
if (fd.is_file(mod_path))
return {path: mod_path, scope: SCOPE_PACKAGE, script: text(fd.slurp(mod_path))};
var core = core_qop.read(path + ext)
if (core)
return {path, scope: SCOPE_CORE, script: text(core)};
return null;
}
function resolve_c_symbol(path, package_ctx)
{
var local_path = package_ctx ? package_ctx : 'local'
var local = `js_${local_path}_${path.replace('/', '_')}_use`
var local_dl_name = `.cell/build/${local_path}/cellmod.dylib`
if (fd.is_file(local_dl_name)) {
if (!open_dls[local_dl_name])
open_dls[local_dl_name] = os.dylib_open(local_dl_name);
if (open_dls[local_dl_name]) {
var local_addr = os.dylib_symbol(open_dls[local_dl_name], local);
if (local_addr) return {symbol: local_addr, scope: SCOPE_LOCAL};
}
}
var static_local_addr = os.load_internal(`js_${local_path}_${path.replace('/', '_')}_use`);
if (static_local_addr) return {symbol: static_local_addr, scope: SCOPE_LOCAL};
var pkg = get_package_from_path(path, package_ctx)
if (pkg) {
var package_sym = `js_${pkg.replace('/', '_')}_use`
var canon_pkg = get_normalized_package(path, package_ctx)
var package_dl_name = `.cell/build/${canon_pkg}/cellmod.dylib`;
if (canon_pkg && fd.is_file(package_dl_name)) {
if (!open_dls[package_dl_name])
open_dls[package_dl_name] = os.dylib_open(package_dl_name);
if (open_dls[package_dl_name]) {
var package_addr = os.dylib_symbol(open_dls[package_dl_name], package_sym);
if (package_addr)
return {symbol:package_addr, scope: SCOPE_PACKAGE, package: canon_pkg};
}
}
var static_package_addr = os.load_internal(`js_${pkg.replace('/', '_')}_use`);
if (static_package_addr)
return {symbol:static_package_addr, scope: SCOPE_PACKAGE, package: canon_pkg};
}
var core_addr = os.load_internal(`js_${path.replace('/', '_')}_use`);
if (core_addr)
return {symbol:core_addr, scope: SCOPE_CORE};
return null
}
function mod_scriptor(name, script)
{
return `(function setup_${name}_module($_){${script}})`
}
function eval_mod(path, script, c_sym)
{
var content = mod_scriptor(path, script)
var fn = js.eval(path, content)
return fn.call(c_sym)
}
// first looks in local
// then in dependencies
// then in core
// package_context: optional package context to resolve relative paths within
Shop.use = function(path, package_context) {
var c_resolve = resolve_c_symbol(path, package_context) || {scope:999}
var mod_resolve = resolve_locator(path, '.cm', package_context) || {scope:999}
var min_scope = Math.min(c_resolve.scope, mod_resolve.scope)
if (min_scope == 999) {
throw new Error(`Module ${path} could not be found`)
}
var cache_key = `${text(min_scope)}::${path}`
if (use_cache[cache_key])
return use_cache[cache_key]
if (c_resolve.scope < mod_resolve.scope)
use_cache[cache_key] = c_resolve.symbol
else if (mod_resolve.scope < c_resolve.scope)
use_cache[cache_key] = eval_mod(mod_resolve.path, mod_resolve.script)
else
use_cache[cache_key] = eval_mod(mod_resolve.path, mod_resolve.script, c_resolve.symbol)
return use_cache[cache_key]
}
Shop.resolve_locator = resolve_locator
// 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 = text(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
}
return Shop