/****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025, Leafscale, LLC - https://www.leafscale.com Project: Zygaena Filename: install.reef Authors: Chris Tusa License: Description: Install command - install packages from archives ******************************************************************************/ module commands.install import sys.args import sys.process import io.file import io.dir import io.path import core.str import core.config import core.database import core.pkgdb import core.package import core.groups import core.repository import core.signing import core.resolver import core.port import core.portindex import util.checksum import util.mtree import util.http as uhttp import util.prompt import util.version as ver import types import util.color import util.priv import exitcodes as ec export fn execute(opts: types.GlobalOptions): int end export fn execute(opts: types.GlobalOptions): int let argc = args.count() // Package arguments start after the command if argc <= opts.cmd_index + 1 print_usage() return ec.EXIT_USAGE() end if // Load configuration and apply command-line overrides let base_cfg = config.load() let cfg = config.apply_overrides(base_cfg, opts.root, opts.prefix, opts.no_chroot) uhttp.enable_proxy(config.get_use_proxy(cfg)) // Auto-refresh port index if configured if config.get_auto_refresh(cfg) portindex.rebuild_port_index() end if // Show root/prefix if set (verbose mode) if opts.verbose let root = config.get_root(cfg) let prefix = config.get_prefix(cfg) if str.length(root) > 0 color.print_info("Using root: " + root) end if if str.length(prefix) > 0 color.print_info("Using prefix: " + prefix) end if end if // Collect all packages to install (expanding groups) mut packages: [string] = new [string](256) mut pkg_count = 0 mut direct_files: [string] = new [string](16) mut direct_count = 0 // Process each package argument (starting after the command) mut i = opts.cmd_index + 1 while i < argc and pkg_count < 256 let pkg_arg = args.get(i) // Skip flags and their values if pkg_arg == "--root" or pkg_arg == "--prefix" i = i + 2 // Skip flag and its value continue elif pkg_arg == "--no-chroot-scripts" i = i + 1 // valueless flag: skip only itself continue end if if str.starts_with(pkg_arg, "-") i = i + 1 continue end if // Check if this is a direct file path to a .pkg.tar.xz if str.ends_with(pkg_arg, ".pkg.tar.xz") and file.fileExists(pkg_arg) direct_files[direct_count] = pkg_arg direct_count = direct_count + 1 i = i + 1 continue end if // Check if this is a group reference if groups.is_group(pkg_arg) // Expand group to member packages mut group_members: [string] = new [string](128) let member_count = groups.expand_group(pkg_arg, group_members, 128) if member_count == 0 color.print_error("Group not found or empty: " + pkg_arg) i = i + 1 continue end if color.print_info("Expanding group " + pkg_arg + " (" + int_to_str(member_count) + " packages)") // Add all group members to packages list mut j = 0 while j < member_count and pkg_count < 256 packages[pkg_count] = group_members[j] pkg_count = pkg_count + 1 j = j + 1 end while else // Regular package packages[pkg_count] = pkg_arg pkg_count = pkg_count + 1 end if i = i + 1 end while // Handle direct file installs — bypass resolver, install immediately if direct_count > 0 let root = get_root(cfg) let root_for_db = config.get_root(cfg) if str.length(root_for_db) > 0 if not database.init_db_rooted(root_for_db) color.print_error("Failed to initialize database in " + root_for_db) return ec.EXIT_DB_ERROR() end if else if not database.init_db() color.print_error("Failed to initialize package database") return ec.EXIT_DB_ERROR() end if end if mut di = 0 while di < direct_count if not install_package(direct_files[di], cfg, root_for_db, opts) color.print_error("Failed to install: " + direct_files[di]) return ec.EXIT_INSTALL_FAILED() end if di = di + 1 end while if pkg_count == 0 return ec.EXIT_SUCCESS() end if end if // Resolve dependencies for all packages // Use rooted resolver if installing to alternate root mut install_order: [string] = new [string](512) let root_for_resolve = config.get_root(cfg) mut install_count = 0 if str.length(root_for_resolve) > 0 install_count = resolver.get_install_order_rooted(packages, pkg_count, install_order, 512, root_for_resolve) else install_count = resolver.get_install_order(packages, pkg_count, install_order, 512) end if // When force/reinstall is set, add requested packages that the resolver // skipped (because they're already installed) back into the install order if opts.force and install_count < pkg_count mut fi = 0 while fi < pkg_count let pkg = packages[fi] if not array_contains(install_order, install_count, pkg) install_order[install_count] = pkg install_count = install_count + 1 end if fi = fi + 1 end while end if if install_count == 0 and pkg_count > 0 color.print_warning("No packages to install (all already installed or not found)") return ec.EXIT_SUCCESS() end if // Separate new installs from already-installed (for display) mut to_install: [string] = new [string](512) mut install_idx = 0 mut dep_idx = 0 while dep_idx < install_count to_install[install_idx] = install_order[dep_idx] install_idx = install_idx + 1 dep_idx = dep_idx + 1 end while // Ask for confirmation (unless -y flag) mut empty_upgrade: [string] = new [string](1) if not prompt.confirm_install(to_install, install_idx, empty_upgrade, 0, opts) color.print_info("Installation cancelled") return ec.EXIT_CANCELLED() end if // Dry-run: show what would be done and exit if opts.dry_run color.print_info("Dry run — no changes made") return ec.EXIT_SUCCESS() end if // Get root path for installation let root = get_root(cfg) // Initialize database in the target root let root_for_db = config.get_root(cfg) if str.length(root_for_db) > 0 if not database.init_db_rooted(root_for_db) color.print_error("Failed to initialize database in " + root_for_db) return ec.EXIT_DB_ERROR() end if else if not database.init_db() color.print_error("Failed to initialize package database") return ec.EXIT_DB_ERROR() end if end if // Install all packages in resolved order (dependencies first) i = 0 while i < install_count let pkg_arg = install_order[i] // Install this package if not install_package(pkg_arg, cfg, root_for_db, opts) color.print_error("Failed to install: " + pkg_arg) color.print_error("Aborting installation - dependency failed") return ec.EXIT_INSTALL_FAILED() end if i = i + 1 end while return ec.EXIT_SUCCESS() end execute // Helper to check if array contains a string fn array_contains(arr: [string], count: int, s: string): bool mut i = 0 while i < count if str.equals(arr[i], s) return true end if i = i + 1 end while return false end array_contains proc print_usage() println("Usage: coral install ") println("") println("Install one or more packages.") println("") println("Arguments:") println(" Package name or path to .pkg.tar.xz file") println("") println("Options:") println(" -y, --yes Don't ask for confirmation") println(" --force Force reinstall if already installed") println("") println("Examples:") println(" coral install vim") println(" coral install /path/to/package.pkg.tar.xz") println(" coral install vim nano htop") end print_usage fn install_package(pkg_arg: string, cfg: config.Config, root_path: string, opts: types.GlobalOptions): bool // Determine if this is a file path or package name let pkg_path = resolve_package_path(pkg_arg, cfg) if str.length(pkg_path) == 0 color.print_error("Package not found: " + pkg_arg) return false end if // Verify signature if required (for local packages not already verified by resolve_package_path) let require_signed = config.get_require_signed_packages(cfg) if require_signed let sig_path = pkg_path + ".sig" if file.fileExists(sig_path) color.print_info("Verifying package signature...") let verify_result = signing.verify_package(pkg_path, sig_path) if not verify_result.success or not verify_result.valid color.print_error("Signature verification failed: " + verify_result.error) return false end if color.print_success("Signature verified (key: " + verify_result.keyid + ")") else color.print_error("Package signature required but not found: " + sig_path) return false end if end if color.print_action("Installing " + pkg_arg + "...") // Create extraction directory let extract_dir = create_extract_dir(cfg) if str.length(extract_dir) == 0 color.print_error("Failed to create extraction directory") return false end if // Extract package color.print_info("Extracting package...") let result = package.extract_package(pkg_path, extract_dir) if not result.success color.print_error(result.error) cleanup_extract_dir(extract_dir) return false end if // Read package info let info = package.read_pkginfo(extract_dir) if str.length(info.name) == 0 color.print_error("Failed to read package info") cleanup_extract_dir(extract_dir) return false end if color.print_info("Package: " + info.name + " " + info.version) // Check for conflicts with installed packages if info.conflicts_count > 0 mut ci = 0 while ci < info.conflicts_count let conflict = info.conflicts[ci] if check_installed(conflict, root_path) color.print_error("Package " + info.name + " conflicts with installed package: " + conflict) if not opts.force color.print_error("Use --force to override conflict check") cleanup_extract_dir(extract_dir) return false end if color.print_warning("Proceeding despite conflict (--force specified)") end if ci = ci + 1 end while end if // Check if already installed (in target root) let already_installed = check_installed(info.name, root_path) mut is_upgrade = false mut old_version = "" if already_installed // Get old package info to check version let old_pkg = get_old_installed(info.name, root_path) old_version = old_pkg.info.version if not str.equals(old_version, info.version) // Different version — upgrade is_upgrade = true color.print_info("Upgrading " + info.name + " " + old_version + " -> " + info.version) else // Same version — skip or reinstall with --force if not opts.force color.print_warning(info.name + " " + info.version + " is already installed (use --force to reinstall)") cleanup_extract_dir(extract_dir) return true end if color.print_info("Reinstalling " + info.name + "...") end if end if let install_root = get_root(cfg) // Resolve script execution context; abort early if confinement is required but impossible. // Defense-in-depth: main.reef already bars non-root for all non-query commands, so this abort // is currently unreachable via the CLI. Kept so confinement fails safely if that global gate is // ever relaxed. if package.script_wants_chroot(install_root, config.get_chroot_scripts(cfg)) and not priv.is_root() color.print_error("--root " + install_root + " runs maintainer scripts confined to the target via chroot, which requires root privileges.") color.print_error("Re-run as root, or pass --no-chroot-scripts to run them on the host (unconfined).") cleanup_extract_dir(extract_dir) return false end if let script_ctx = package.resolve_script_ctx(install_root, config.get_chroot_scripts(cfg), priv.is_root()) if is_upgrade // Upgrade flow: pre-upgrade → remove old files → install new files → post-upgrade // Run pre-upgrade from stored scripts (old package) let old_scripts = database.get_scripts_dir(info.name) if database.has_stored_scripts(info.name) color.print_info("Running pre-upgrade script...") if not package.run_stored_pre_upgrade(script_ctx, old_scripts, old_version, info.version) color.print_error("Pre-upgrade script failed, aborting") cleanup_extract_dir(extract_dir) return false end if end if // Remove old files color.print_info("Removing old version files...") mut old_files: [string] = new [string](4096) let old_file_count = get_old_files(info.name, old_files, 4096, root_path) if old_file_count > 0 package.remove_files(old_files, old_file_count, install_root) end if // Load old manifest for config file checksum protection mut old_manifest_entries: [mtree.ManifestEntry] = new [mtree.ManifestEntry](8192) let old_manifest_count = load_old_manifest(info.name, root_path, old_manifest_entries, 8192) // Install new files (with upgrade config protection) color.print_info("Installing files...") if old_manifest_count > 0 if not package.install_files_upgrade(extract_dir, install_root, old_manifest_entries, old_manifest_count) color.print_error("Failed to install files") cleanup_extract_dir(extract_dir) return false end if else if not package.install_files(extract_dir, install_root) color.print_error("Failed to install files") cleanup_extract_dir(extract_dir) return false end if end if // Run post-upgrade from new package's scripts if package.has_scripts(extract_dir) color.print_info("Running post-upgrade script...") if not package.run_post_upgrade(script_ctx, extract_dir, old_version, info.version) color.print_warning("Post-upgrade script failed") end if end if else // Fresh install flow // Run pre-install script (abort on failure) if package.has_scripts(extract_dir) color.print_info("Running pre-install script...") if not package.run_pre_install(script_ctx, extract_dir, info.version) color.print_error("Pre-install script failed") cleanup_extract_dir(extract_dir) return false end if end if // Install files to target root color.print_info("Installing files...") if not package.install_files(extract_dir, install_root) color.print_error("Failed to install files") cleanup_extract_dir(extract_dir) return false end if // Run post-install script (warn on failure, don't abort) if package.has_scripts(extract_dir) color.print_info("Running post-install script...") if not package.run_post_install(script_ctx, extract_dir, info.version) color.print_warning("Post-install script failed") end if end if end if // Register in database (use rooted version if root is set) color.print_info("Registering package...") if not register_installed_fast_rooted(info, extract_dir, root_path) color.print_error("Failed to register package in database") // Files are installed but not registered - warn but don't fail end if // Store scripts for later use (removal) if package.has_scripts(extract_dir) if not store_scripts_rooted(info.name, extract_dir, root_path) color.print_warning("Failed to store scripts for removal") end if end if // Store config files list for upgrade handling if not store_config_files_rooted(info.name, extract_dir, root_path) color.print_warning("Failed to store config files list") end if // Update package database indexes if not pkgdb.rebuild_indexes_rooted(root_path) color.print_warning("Failed to update package indexes") end if // Cleanup cleanup_extract_dir(extract_dir) color.print_success("Installed " + info.name + " " + info.version) return true end install_package // Check if package is installed, using rooted check if root is set fn check_installed(name: string, root_path: string): bool if str.length(root_path) > 0 return database.is_installed_rooted(name, root_path) end if return database.is_installed(name) end check_installed // Register package using rooted version if root is set fn register_installed_fast_rooted(info: types.PackageInfo, extract_dir: string, root_path: string): bool mut files: [string] = new [string](1) mut pkg = new_installed_pkg(info, files, 0) if str.length(root_path) > 0 return database.register_from_extract_dir_rooted(pkg, extract_dir, root_path) end if return database.register_from_extract_dir(pkg, extract_dir) end register_installed_fast_rooted // Store scripts using rooted version if root is set fn store_scripts_rooted(name: string, extract_dir: string, root_path: string): bool if str.length(root_path) > 0 return database.store_scripts_rooted(name, extract_dir, root_path) end if return database.store_scripts(name, extract_dir) end store_scripts_rooted // Store config files using rooted version if root is set fn store_config_files_rooted(name: string, extract_dir: string, root_path: string): bool if str.length(root_path) > 0 return database.store_config_files_rooted(name, extract_dir, root_path) end if return database.store_config_files(name, extract_dir) end store_config_files_rooted // Find package file - either direct path, repository, or search in packages directory fn resolve_package_path(pkg_arg: string, cfg: config.Config): string // If it's a file path that exists, use it directly if file.fileExists(pkg_arg) return pkg_arg end if // Search in local packages cache first let packages_dir = config.get_packages_dir(cfg) // Find all matching packages and select highest version mut entries: [string] = new [string](256) let entry_count = dir.list_dir(packages_dir, entries, 256) // Collect all matching package files mut best_file = "" mut best_version = "" mut best_release = 0 mut i = 0 while i < entry_count let entry = entries[i] // Check if entry starts with package name and has correct suffix if str.starts_with(entry, pkg_arg + "-") and str.ends_with(entry, ".pkg.tar.xz") // Extract version from filename let entry_version = ver.extract_version_from_filename(entry, pkg_arg) let entry_release = ver.extract_release_from_filename(entry) // Compare with current best if str.length(best_file) == 0 // First match best_file = entry best_version = entry_version best_release = entry_release else // Compare versions let cmp = ver.compare_versions(entry_version, best_version) if cmp > 0 // Entry has higher version best_file = entry best_version = entry_version best_release = entry_release elif cmp == 0 and entry_release > best_release // Same version but higher release best_file = entry best_version = entry_version best_release = entry_release end if end if end if i = i + 1 end while // Return the best matching package if str.length(best_file) > 0 return path.join_path(packages_dir, best_file) end if // Also try with .pkg.tar.xz appended let direct_path = path.join_path(packages_dir, pkg_arg + ".pkg.tar.xz") if file.fileExists(direct_path) return direct_path end if // Try to build from source if configured if config.get_fallback_to_source(cfg) let port_result = port.find(pkg_arg) if port_result.success color.print_info("No binary package found for " + pkg_arg + ", building from source...") let build_cmd = "coral build -y " + pkg_arg let build_pid = process.process_spawn_shell(build_cmd) if build_pid > 0 let build_exit = process.process_wait(build_pid) if build_exit == 0 // Build succeeded, look for the package it produced let p = port_result.port let built_pkg = p.info.name + "-" + p.info.version + "-" + int_to_str(p.info.release) + ".pkg.tar.xz" let built_path = path.join_path(packages_dir, built_pkg) if file.fileExists(built_path) return built_path end if end if end if color.print_warning("Source build failed for " + pkg_arg + ", trying repositories...") end if end if // Try to find in repositories and download let remote_result = repository.find_remote_package(pkg_arg) if remote_result.success let remote_pkg = remote_result.pkg color.print_info("Found " + pkg_arg + " " + remote_pkg.version + " in repository: " + remote_pkg.repo_name) // Download the package let downloaded_path = repository.download_package(remote_pkg) if str.length(downloaded_path) > 0 // Verify checksum if available if str.length(remote_pkg.sha256) > 0 color.print_info("Verifying checksum...") if not checksum.verify_checksum(downloaded_path, remote_pkg.sha256) color.print_error("Checksum verification failed!") return "" end if color.print_success("Checksum verified") end if // Verify signature if required or available let require_signed = config.get_require_signed_packages(cfg) if require_signed or str.length(remote_pkg.signature) > 0 let sig_path = downloaded_path + ".sig" // Download signature file if we don't have it if not file.fileExists(sig_path) and str.length(remote_pkg.signature) > 0 color.print_info("Downloading signature...") let sig_url = get_sig_url(remote_pkg) if not download_signature(sig_url, sig_path) if require_signed color.print_error("Failed to download package signature") return "" else color.print_warning("Could not download signature, skipping verification") end if end if end if // Verify signature if we have it if file.fileExists(sig_path) color.print_info("Verifying signature...") let verify_result = signing.verify_package(downloaded_path, sig_path) if verify_result.success and verify_result.valid color.print_success("Signature verified (key: " + verify_result.keyid + ")") else if require_signed color.print_error("Signature verification failed: " + verify_result.error) return "" else color.print_warning("Signature verification failed: " + verify_result.error) end if end if elif require_signed color.print_error("Package signature required but not available") return "" end if end if return downloaded_path end if end if return "" end resolve_package_path // Create a temporary extraction directory fn create_extract_dir(cfg: config.Config): string let work_dir = config.get_work_dir(cfg) let pid = process.getpid() let extract_dir = path.join_path(work_dir, "install_" + int_to_str(pid)) if dir.dir_exists(extract_dir) // Clean existing let cmd = "rm -rf \"" + extract_dir + "\"" let rm_pid = process.process_spawn_shell(cmd) if rm_pid > 0 process.process_wait(rm_pid) end if end if if not dir.create_dir_all(extract_dir) return "" end if return extract_dir end create_extract_dir // Cleanup extraction directory fn cleanup_extract_dir(dir_path: string): bool if str.length(dir_path) == 0 return true end if let cmd = "rm -rf \"" + dir_path + "\"" let pid = process.process_spawn_shell(cmd) if pid < 0 return false end if process.process_wait(pid) return true end cleanup_extract_dir // Get installation root from config (can be overridden with --root) fn get_root(cfg: config.Config): string let root = config.get_root(cfg) if str.length(root) == 0 return "/" end if return root end get_root // Check for file conflicts with other installed packages // TODO: Implement efficient conflict detection using a file database fn check_conflicts(files: [string], file_count: int, pkg_name: string): bool // Skip conflict checking for performance - file overwrites are handled // by the manifest-driven installer and dependencies are managed separately return true end check_conflicts // Helper to create InstalledPackage fn new_installed_pkg(info: types.PackageInfo, files: [string], file_count: int): types.InstalledPackage mut pkg = types.new_installed_package() pkg.info = info pkg.install_date = get_date_string() pkg.install_reason = "explicit" pkg.files = files pkg.files_count = file_count return pkg end new_installed_pkg // Get current date as string (simplified) fn get_date_string(): string // Use date command for simplicity // TODO: Use proper time functions when available return "2025-01-24" end get_date_string // Get installed package info (root-aware) fn get_old_installed(name: string, root_path: string): types.InstalledPackage if str.length(root_path) > 0 return database.get_installed_rooted(name, root_path) end if return database.get_installed(name) end get_old_installed // Get old file list for a package (root-aware) fn get_old_files(name: string, files: [string], max_count: int, root_path: string): int if str.length(root_path) > 0 return database.get_files_rooted(name, files, max_count, root_path) end if return database.get_files(name, files, max_count) end get_old_files // Load old manifest from database for upgrade config protection fn load_old_manifest(name: string, root_path: string, entries: [mtree.ManifestEntry], max_count: int): int let manifest_path = database.get_manifest_path(name) if not file.fileExists(manifest_path) return 0 end if let content = file.readFile(manifest_path) if str.length(content) == 0 return 0 end if return mtree.parse_manifest(content, entries, max_count) end load_old_manifest // Helper: convert int to string fn int_to_str(n: int): string if n == 0 return "0" end if mut negative = false mut value = n if n < 0 negative = true value = 0 - n end if mut result = "" while value > 0 let digit = value % 10 result = str.concat(str.substring("0123456789", digit, 1), result) value = value / 10 end while if negative result = str.concat("-", result) end if return result end int_to_str // Build signature URL from remote package info fn get_sig_url(pkg: repository.RemotePackage): string mut base_url = pkg.repo_url if not str.ends_with(base_url, "/") base_url = base_url + "/" end if // If signature field contains a full URL, use it if str.starts_with(pkg.signature, "http") return pkg.signature end if // Otherwise construct from filename return base_url + "packages/" + pkg.filename + ".sig" end get_sig_url // Download a signature file fn download_signature(url: string, dest: string): bool return uhttp.download(url, dest) end download_signature end module