1144 lines
30 KiB
Plaintext
1144 lines
30 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 crypto = use('crypto')
|
|
var utf8 = use('utf8')
|
|
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 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 cfg = Shop.load_config(ctx)
|
|
|
|
if (!cfg || !cfg.dependencies)
|
|
return null
|
|
|
|
var locator = cfg.dependencies[mod]
|
|
if (!locator)
|
|
return null
|
|
|
|
var parsed = Shop.parse_locator(locator)
|
|
if (!parsed)
|
|
return null
|
|
|
|
return parsed.path
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
|
|
if (!content.length) return {}
|
|
return toml.decode(text(content))
|
|
}
|
|
|
|
// Save cell.toml configuration
|
|
Shop.save_config = function(config) {
|
|
fd.slurpwrite(shop_path, utf8.encode(toml.encode(config)));
|
|
}
|
|
|
|
// Load lock.toml configuration
|
|
Shop.load_lock = function() {
|
|
var path = '.cell/lock.toml'
|
|
|
|
if (!fd.is_file(path))
|
|
return {}
|
|
|
|
var content = text(fd.slurp(path))
|
|
if (!content.length) return {}
|
|
return toml.decode(content)
|
|
}
|
|
|
|
// Save lock.toml configuration
|
|
Shop.save_lock = function(lock) {
|
|
fd.slurpwrite('.cell/lock.toml', utf8.encode(toml.encode(lock)));
|
|
}
|
|
|
|
// Initialize .cell directory structure
|
|
Shop.init = function() {
|
|
if (!fd.is_dir('.cell')) {
|
|
fd.mkdir('.cell')
|
|
}
|
|
|
|
if (!fd.is_dir('.cell/modules')) {
|
|
fd.mkdir('.cell/modules')
|
|
}
|
|
|
|
if (!fd.is_dir('.cell/build')) {
|
|
fd.mkdir('.cell/build')
|
|
}
|
|
|
|
if (!fd.is_dir('.cell/cache')) {
|
|
fd.mkdir('.cell/cache')
|
|
}
|
|
|
|
if (!fd.is_file('.cell/lock.toml')) {
|
|
fd.slurpwrite('.cell/lock.toml', '# Lock file for module integrity\n');
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Parse module locator (e.g., "https://git.world/jj/mod@v0.6.3")
|
|
Shop.parse_locator = function(locator) {
|
|
var protocol = null
|
|
var path = locator
|
|
var version = null
|
|
|
|
// Extract method (e.g., "https")
|
|
if (locator.includes('://')) {
|
|
var methodParts = locator.split('://')
|
|
protocol = methodParts[0]
|
|
path = methodParts[1]
|
|
}
|
|
|
|
// Extract version if present
|
|
if (path.includes('@')) {
|
|
var versionParts = path.split('@')
|
|
path = versionParts[0]
|
|
version = versionParts[1]
|
|
}
|
|
|
|
// Extract name (last part of path)
|
|
var name = path.split('/').pop()
|
|
|
|
return {
|
|
protocol,
|
|
path,
|
|
name,
|
|
version
|
|
}
|
|
}
|
|
|
|
// Convert module locator to download URL
|
|
Shop.get_download_url = function(locator, commit_hash) {
|
|
var parsed = Shop.parse_locator(locator)
|
|
if (!parsed) return null
|
|
|
|
if (parsed.path.includes('gitea.')) {
|
|
var parts = parsed.path.split('/')
|
|
var host = parts[0]
|
|
var user = parts[1]
|
|
var repo = parts[2]
|
|
|
|
if (!commit_hash) {
|
|
log.error("No commit hash available for download URL")
|
|
return null
|
|
}
|
|
|
|
return 'https://' + host + '/' + user + '/' + repo + '/archive/' + commit_hash + '.zip'
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
// Add a dependency
|
|
Shop.add_dependency = function(alias, locator) {
|
|
var config = Shop.load_config()
|
|
if (!config)
|
|
throw new Error("No cell.toml found");
|
|
|
|
if (!config.dependencies)
|
|
config.dependencies = {}
|
|
|
|
if (config.dependencies[alias] == locator)
|
|
throw new Error("Dependency '" + alias + "' already exists with the same version");
|
|
|
|
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 parts = parsed.path.split('/')
|
|
// Gitea pattern: gitea.pockle.world/user/repo@branch
|
|
if (parsed.path.includes('gitea.')) {
|
|
var host = parts[0]
|
|
var user = parts[1]
|
|
var repo = parts[2]
|
|
var url = 'https://' + host + '/api/v1/repos/' + user + '/' + repo + '/branches/'
|
|
if (parsed.version) url += parsed.version
|
|
return url
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
// Extract commit hash from API response
|
|
Shop.extract_commit_hash = function(locator, response) {
|
|
if (!response) return null
|
|
|
|
var data = json.decode(response)
|
|
|
|
if (locator.includes('gitea.')) {
|
|
// Gitea: response.commit.id
|
|
if (Array.isArray(data))
|
|
data = data[0]
|
|
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_dir(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)
|
|
fd.slurpwrite(full_path, content)
|
|
}
|
|
}
|
|
|
|
// 4. Update Lock (only for root package)
|
|
log.console("Installed " + alias)
|
|
return { commit: commit_hash, locator: locator }
|
|
}
|
|
|
|
function lock_locator(loc)
|
|
{
|
|
var lock = Shop.load_lock()
|
|
|
|
}
|
|
|
|
Shop.check_cache = function(locator) {
|
|
var parsed = Shop.parse_locator(locator)
|
|
if (!parsed) return null
|
|
|
|
var cache_path = `.cell/cache/${parsed.path}.zip`
|
|
if (fd.is_file(cache_path)) {
|
|
log.console("Found cached zip: " + cache_path)
|
|
return true
|
|
}
|
|
|
|
|
|
return false
|
|
}
|
|
|
|
// Verify dependencies
|
|
Shop.verify = function(locator) {
|
|
// each locator should be a package
|
|
|
|
}
|
|
|
|
var open_dls = {}
|
|
|
|
function resolve_locator(path, ext, ctx)
|
|
{
|
|
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)
|
|
{
|
|
// TODO: need a safe name here
|
|
return `(function setup_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
|
|
|
|
// Get cache path for a locator and commit
|
|
function get_cache_path(locator, commit) {
|
|
var parsed = Shop.parse_locator(locator)
|
|
if (!parsed) return null
|
|
|
|
var slug = parsed.path.split('/').join('_')
|
|
return `.cell/cache/${slug}_${commit}.zip`
|
|
}
|
|
|
|
function rm_recursive(path) {
|
|
var st = fd.stat(path)
|
|
if (!st) return
|
|
|
|
if (!st.isDirectory) {
|
|
fd.unlink(path)
|
|
return
|
|
}
|
|
|
|
var list = fd.readdir(path)
|
|
if (list) {
|
|
for (var i = 0; i < list.length; i++) {
|
|
var item = list[i]
|
|
if (item == '.' || item == '..') continue
|
|
rm_recursive(path + "/" + item)
|
|
}
|
|
}
|
|
fd.rmdir(path)
|
|
}
|
|
|
|
function get_all_files(dir, prefix, results) {
|
|
prefix = prefix || ""
|
|
results = results || []
|
|
|
|
var list = fd.readdir(dir)
|
|
if (!list) return results
|
|
|
|
for (var i = 0; i < list.length; i++) {
|
|
var item = list[i]
|
|
if (item == '.' || item == '..') continue
|
|
|
|
var full_path = dir + "/" + item
|
|
var rel_path = prefix ? prefix + "/" + item : item
|
|
|
|
var st = fd.stat(full_path)
|
|
if (st.isDirectory) {
|
|
get_all_files(full_path, rel_path, results)
|
|
} else {
|
|
results.push(rel_path)
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
|
|
// Verify zip contents against target directory
|
|
function verify_zip_contents(zip, target_dir) {
|
|
var count = zip.count()
|
|
var expected_files = {}
|
|
for (var i = 0; i < count; i++) {
|
|
if (zip.is_directory(i)) continue
|
|
var filename = zip.get_filename(i)
|
|
var parts = filename.split('/')
|
|
if (parts.length > 1) {
|
|
parts.shift()
|
|
var rel_path = parts.join('/')
|
|
expected_files[rel_path] = true
|
|
|
|
var full_path = target_dir + '/' + rel_path
|
|
if (!fd.is_file(full_path)) return false
|
|
|
|
var content_zip = zip.slurp(filename)
|
|
var content_disk = fd.slurp(full_path)
|
|
|
|
if (content_zip.length != content_disk.length) return false
|
|
|
|
var hash_zip = text(crypto.blake2(content_zip), 'h')
|
|
var hash_disk = text(crypto.blake2(content_disk), 'h')
|
|
|
|
if (hash_zip != hash_disk) return false
|
|
}
|
|
}
|
|
|
|
// Check for extra files
|
|
var existing_files = get_all_files(target_dir)
|
|
for (var i = 0; i < existing_files.length; i++) {
|
|
if (!expected_files[existing_files[i]]) return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Install from a raw locator (not from config)
|
|
function install_from_locator(locator, locked_hash, expected_zip_hash) {
|
|
var parsed = Shop.parse_locator(locator)
|
|
var target_dir = '.cell/modules/' + parsed.path
|
|
|
|
// 1. Get Commit Hash - use locked hash if provided, otherwise fetch
|
|
var commit_hash = locked_hash
|
|
if (!commit_hash) {
|
|
var api_url = Shop.get_api_url(locator)
|
|
if (api_url) {
|
|
try {
|
|
var resp = http.fetch(api_url)
|
|
var resp_text = text(resp)
|
|
commit_hash = Shop.extract_commit_hash(locator, resp_text)
|
|
} catch (e) {
|
|
log.console("Warning: Failed to fetch API info: " + e)
|
|
}
|
|
}
|
|
} else {
|
|
log.console("Using locked commit: " + commit_hash)
|
|
}
|
|
|
|
if (!commit_hash) {
|
|
log.error("Could not determine commit hash for " + locator)
|
|
return null
|
|
}
|
|
|
|
// 2. Check Cache / Download Zip
|
|
var cache_path = get_cache_path(locator, commit_hash)
|
|
var zip_blob = null
|
|
var zip_hash = null
|
|
var use_cache = false
|
|
|
|
if (fd.is_file(cache_path)) {
|
|
log.console("Found cached zip: " + cache_path)
|
|
try {
|
|
var cached = fd.slurp(cache_path)
|
|
var computed_hash = text(crypto.blake2(cached), 'h')
|
|
|
|
if (expected_zip_hash && computed_hash != expected_zip_hash) {
|
|
log.console("Cache hash mismatch. Expected: " + expected_zip_hash + ", Got: " + computed_hash)
|
|
log.console("Redownloading...")
|
|
} else {
|
|
zip_blob = cached
|
|
zip_hash = computed_hash
|
|
use_cache = true
|
|
}
|
|
} catch (e) {
|
|
log.error("Failed to read cache: " + e)
|
|
}
|
|
}
|
|
|
|
if (!use_cache) {
|
|
var download_url = Shop.get_download_url(locator, commit_hash)
|
|
if (!download_url) {
|
|
log.error("Could not determine download URL for " + locator)
|
|
return null
|
|
}
|
|
|
|
log.console("Downloading from " + download_url)
|
|
try {
|
|
zip_blob = http.fetch(download_url)
|
|
zip_hash = text(crypto.blake2(zip_blob), 'h')
|
|
|
|
// Save to cache
|
|
ensure_dir(cache_path.substring(0, cache_path.lastIndexOf('/')))
|
|
fd.slurpwrite(cache_path, zip_blob)
|
|
log.console("Cached to " + cache_path)
|
|
|
|
} catch (e) {
|
|
log.error(e)
|
|
return null
|
|
}
|
|
}
|
|
|
|
// 3. Verify and Unpack
|
|
var zip = miniz.read(zip_blob)
|
|
if (!zip) throw new Error("Failed to read zip archive")
|
|
|
|
var needs_unpack = !use_cache
|
|
|
|
// If using cache, verify existing installation strictly
|
|
if (use_cache && fd.is_dir(target_dir)) {
|
|
if (!verify_zip_contents(zip, target_dir)) {
|
|
log.console("Verification failed for " + locator + ". Reinstalling...")
|
|
needs_unpack = true
|
|
}
|
|
} else if (use_cache && !fd.is_dir(target_dir)) {
|
|
needs_unpack = true
|
|
}
|
|
|
|
if (needs_unpack) {
|
|
if (fd.is_dir(target_dir)) {
|
|
log.console("Clearing module directory for fresh install...")
|
|
rm_recursive(target_dir)
|
|
}
|
|
|
|
log.console("Unpacking to " + target_dir)
|
|
ensure_dir(target_dir)
|
|
|
|
var count = zip.count()
|
|
for (var i = 0; i < count; i++) {
|
|
if (zip.is_directory(i)) continue
|
|
var filename = zip.get_filename(i)
|
|
|
|
var parts = filename.split('/')
|
|
if (parts.length > 1) {
|
|
parts.shift()
|
|
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)
|
|
fd.slurpwrite(full_path, content)
|
|
}
|
|
}
|
|
} else {
|
|
log.console("Verified existing installation.")
|
|
}
|
|
|
|
return { commit: commit_hash, locator: locator, path: parsed.path, zip_hash: zip_hash }
|
|
}
|
|
|
|
// High-level: Add a package, install it, and install all transitive dependencies
|
|
// Like `bun add` or `npm install <pkg>`
|
|
Shop.get = function(locator, alias) {
|
|
Shop.init()
|
|
|
|
var parsed = Shop.parse_locator(locator)
|
|
if (!alias) alias = parsed.name
|
|
|
|
log.console("Adding dependency: " + alias + " = " + locator)
|
|
|
|
// Add to config
|
|
var config = Shop.load_config() || { dependencies: {} }
|
|
if (!config.dependencies) config.dependencies = {}
|
|
config.dependencies[alias] = locator
|
|
Shop.save_config(config)
|
|
|
|
// Install the package and dependencies
|
|
var queue = [locator]
|
|
var processed = {}
|
|
var lock = Shop.load_lock(null)
|
|
|
|
while (queue.length > 0) {
|
|
var current_locator = queue.shift()
|
|
if (processed[current_locator]) continue
|
|
processed[current_locator] = true
|
|
|
|
log.console("Installing " + current_locator + "...")
|
|
|
|
var lock_info = lock[current_locator] || lock[Shop.parse_locator(current_locator).name]
|
|
var locked_hash = lock_info ? lock_info.commit : null
|
|
var zip_hash = lock_info ? lock_info.zip_hash : null
|
|
|
|
var result = install_from_locator(current_locator, locked_hash, zip_hash)
|
|
if (result) {
|
|
lock[current_locator] = {
|
|
locator: current_locator,
|
|
commit: result.commit,
|
|
zip_hash: result.zip_hash,
|
|
updated: time.number()
|
|
}
|
|
|
|
// Read package config to find dependencies
|
|
var parsed = Shop.parse_locator(current_locator)
|
|
var pkg_config = Shop.load_config(parsed.path)
|
|
|
|
if (pkg_config && pkg_config.dependencies) {
|
|
for (var k in pkg_config.dependencies) {
|
|
var dep_locator = pkg_config.dependencies[k]
|
|
if (!processed[dep_locator]) {
|
|
queue.push(dep_locator)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if (current_locator == locator) {
|
|
log.error("Failed to install requested package " + alias)
|
|
return false
|
|
} else {
|
|
log.error("Failed to install dependency " + current_locator)
|
|
}
|
|
}
|
|
}
|
|
|
|
Shop.save_lock(lock)
|
|
log.console("Done.")
|
|
return true
|
|
}
|
|
|
|
// High-level: Update a specific package or all packages
|
|
// Like `bun update` or `bun update <pkg>`
|
|
Shop.update_all = function(alias) {
|
|
var config = Shop.load_config()
|
|
if (!config || !config.dependencies) {
|
|
log.console("No dependencies to update.")
|
|
return
|
|
}
|
|
|
|
var lock = Shop.load_lock()
|
|
var queue = []
|
|
var processed = {}
|
|
|
|
// Initialize queue
|
|
if (alias) {
|
|
if (config.dependencies[alias]) {
|
|
queue.push(config.dependencies[alias])
|
|
} else {
|
|
log.error("Dependency not found: " + alias)
|
|
return
|
|
}
|
|
} else {
|
|
for (var k in config.dependencies) {
|
|
queue.push(config.dependencies[k])
|
|
}
|
|
}
|
|
|
|
while (queue.length > 0) {
|
|
var locator = queue.shift()
|
|
if (processed[locator]) continue
|
|
processed[locator] = true
|
|
|
|
// Find existing lock info
|
|
var lock_info = lock[locator]
|
|
var local_hash = lock_info ? lock_info.commit : null
|
|
var local_zip_hash = lock_info ? lock_info.zip_hash : null
|
|
|
|
var api_url = Shop.get_api_url(locator)
|
|
var remote_hash = null
|
|
|
|
// Check for updates if possible
|
|
if (api_url) {
|
|
try {
|
|
var resp = http.fetch(api_url)
|
|
remote_hash = Shop.extract_commit_hash(locator, text(resp))
|
|
} catch (e) {
|
|
log.console("Warning: Could not check for updates for " + locator)
|
|
}
|
|
}
|
|
|
|
var target_hash = remote_hash || local_hash
|
|
if (!target_hash) {
|
|
log.error("Could not resolve commit for " + locator)
|
|
continue
|
|
}
|
|
|
|
var is_update = remote_hash && local_hash && (remote_hash != local_hash)
|
|
if (is_update) {
|
|
log.console("Updating " + locator + " " + local_hash.substring(0,8) + " -> " + remote_hash.substring(0,8))
|
|
} else {
|
|
log.console("Checking " + locator + "...")
|
|
}
|
|
|
|
// Install/Verify
|
|
// If updating, we pass null as local_zip_hash to force fresh download/check
|
|
// If verifying, we pass local_zip_hash
|
|
var result = install_from_locator(locator, target_hash, is_update ? null : local_zip_hash)
|
|
|
|
if (result) {
|
|
// Update lock
|
|
lock[locator] = {
|
|
locator: locator,
|
|
commit: result.commit,
|
|
zip_hash: result.zip_hash,
|
|
updated: time.number()
|
|
}
|
|
|
|
// Read package config to find dependencies
|
|
var parsed = Shop.parse_locator(locator)
|
|
var pkg_config = Shop.load_config(parsed.path)
|
|
|
|
if (pkg_config && pkg_config.dependencies) {
|
|
for (var k in pkg_config.dependencies) {
|
|
var dep_locator = pkg_config.dependencies[k]
|
|
if (!processed[dep_locator]) {
|
|
queue.push(dep_locator)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Shop.save_lock(lock)
|
|
log.console("Update complete.")
|
|
}
|
|
|
|
// High-level: Remove a package and clean up
|
|
// Like `bun remove`
|
|
Shop.remove = function(alias) {
|
|
var config = Shop.load_config()
|
|
if (!config || !config.dependencies || !config.dependencies[alias]) {
|
|
log.error("Dependency not found: " + alias)
|
|
return false
|
|
}
|
|
|
|
var locator = config.dependencies[alias]
|
|
var parsed = Shop.parse_locator(locator)
|
|
var target_dir = '.cell/modules/' + parsed.path
|
|
|
|
// Remove from config
|
|
delete config.dependencies[alias]
|
|
Shop.save_config(config)
|
|
|
|
// Remove from lock
|
|
var lock = Shop.load_lock()
|
|
if (lock[locator]) delete lock[locator]
|
|
if (lock[alias]) delete lock[alias] // Cleanup old format
|
|
Shop.save_lock(lock)
|
|
|
|
// Remove directory
|
|
if (fd.is_dir(target_dir)) {
|
|
log.console("Removing " + target_dir)
|
|
try {
|
|
fd.rmdir(target_dir)
|
|
} catch (e) {
|
|
log.error("Failed to remove directory: " + e)
|
|
}
|
|
}
|
|
|
|
log.console("Removed " + alias)
|
|
return true
|
|
}
|
|
|
|
// Install all dependencies from config (like `bun install`)
|
|
Shop.install_all = function() {
|
|
Shop.init()
|
|
|
|
var config = Shop.load_config()
|
|
if (!config || !config.dependencies) {
|
|
log.console("No dependencies to install.")
|
|
return true
|
|
}
|
|
|
|
var lock = Shop.load_lock(null)
|
|
var queue = []
|
|
var processed = {}
|
|
|
|
for (var alias in config.dependencies) {
|
|
queue.push(config.dependencies[alias])
|
|
}
|
|
|
|
while (queue.length > 0) {
|
|
var locator = queue.shift()
|
|
if (processed[locator]) continue
|
|
processed[locator] = true
|
|
|
|
log.console("Installing " + locator + "...")
|
|
|
|
var lock_info = lock[locator] || lock[Shop.parse_locator(locator).name] // Fallback to old format check
|
|
var locked_hash = lock_info ? lock_info.commit : null
|
|
var zip_hash = lock_info ? lock_info.zip_hash : null
|
|
|
|
var result = install_from_locator(locator, locked_hash, zip_hash)
|
|
if (result) {
|
|
lock[locator] = {
|
|
locator: locator,
|
|
commit: result.commit,
|
|
zip_hash: result.zip_hash,
|
|
updated: time.number()
|
|
}
|
|
|
|
// Read package config to find dependencies
|
|
var parsed = Shop.parse_locator(locator)
|
|
var pkg_config = Shop.load_config(parsed.path)
|
|
|
|
if (pkg_config && pkg_config.dependencies) {
|
|
for (var k in pkg_config.dependencies) {
|
|
var dep_locator = pkg_config.dependencies[k]
|
|
if (!processed[dep_locator]) {
|
|
queue.push(dep_locator)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Shop.save_lock(lock)
|
|
log.console("Done.")
|
|
return true
|
|
}
|
|
|
|
|
|
// 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 |