Files
el/epm/src/install.el
Will Anderson a6d093536a
El SDK CI - dev / build-and-test (pull_request) Failing after 7s
ci: fix gen2/gen3 gcc flags and step name formatting
- add -lm (el_runtime.c uses pow/sqrt/log/sin/cos/exp)
- add -Wl,--allow-multiple-definition to gen2 (is_digit/is_whitespace
  defined in both elc-bootstrap.c and el_runtime.c; bootstrap predates
  the text-processing primitives commit)
- remove colon from Self-host step name (Gitea YAML parser rejects it)
- replace em dashes in step names with hyphens
2026-05-05 03:04:54 -05:00

407 lines
16 KiB
EmacsLisp

// epm/src/install.el dependency resolution and vessel installation
//
// Reads the current project's manifest.el, walks the full dependency graph
// by fetching each dep from the Gitea registry, detects cycles, and produces
// a topological install order (dependencies before dependents).
//
// Installed vessels land in: ~/.el/packages/<name>/<version>/
// Installed tracking: ~/.el/packages/installed.json
//
// Cycle detection: each recursion carries a chain of names currently on
// the call stack. If a dep already appears in the chain it's a cycle.
//
// Algorithm:
// 1. Parse manifest.el to get direct deps
// 2. For each dep, fetch its metadata from the Gitea registry
// 3. Recurse into that dep's own deps (depth-first)
// 4. Append dep to order after all its transitive deps
// 5. Deduplicate: skip already-ordered vessels
// Install paths
// packages_dir returns the root directory for installed vessels.
fn packages_dir() -> String {
let home: String = env("HOME")
return home + "/.el/packages"
}
// installed_json_path returns the path to the installed-vessel tracker.
fn installed_json_path() -> String {
return packages_dir() + "/installed.json"
}
// Installed tracking
// read_installed reads installed.json and returns it as a JSON object string.
// Returns "{}" if the file is missing or empty.
fn read_installed() -> String {
let path: String = installed_json_path()
let content: String = fs_read(path)
if str_eq(content, "") { return "{}" }
let trimmed: String = str_trim(content)
if str_eq(trimmed, "") { return "{}" }
return trimmed
}
// write_installed writes a JSON object string to installed.json.
fn write_installed(content: String) -> Void {
let path: String = installed_json_path()
fs_write(path, content)
}
// installed_version returns the currently installed version of a vessel,
// or "" if it is not installed.
fn installed_version(name: String) -> String {
let installed: String = read_installed()
return json_get_string(installed, name)
}
// json_set_key inserts or replaces a string key/value pair in a JSON object.
// The object is expected to be a flat { "k":"v", ... } structure.
// Returns the updated JSON object string.
fn json_set_key(obj: String, key: String, value: String) -> String {
let esc_key: String = json_escape_string(key)
let esc_val: String = json_escape_string(value)
let pair: String = "\"" + esc_key + "\":\"" + esc_val + "\""
let search_key: String = "\"" + esc_key + "\":"
// If the key already exists, replace its value in-place.
let key_pos: Int = str_index_of(obj, search_key)
if key_pos >= 0 {
// Find start of value: skip past search_key
let val_start: Int = key_pos + str_len(search_key)
let after_colon: String = str_slice(obj, val_start, str_len(obj))
let trimmed_after: String = str_trim(after_colon)
// The value is a quoted string find its extent
let q1: Int = str_index_of(trimmed_after, "\"")
if q1 >= 0 {
let inside: String = str_slice(trimmed_after, q1 + 1, str_len(trimmed_after))
let q2: Int = str_index_of(inside, "\"")
if q2 >= 0 {
// Reconstruct: prefix + new pair + suffix
let prefix: String = str_slice(obj, 0, key_pos)
// after the closing quote of the old value
let old_val_end_in_inside: Int = q2 + 1
// offset back to full string: key_pos + str_len(search_key) + leading whitespace + q1 + 1 + q2 + 1
// Simpler: rebuild from scratch without the old entry, then inject
// Strategy: remove old entry by rebuilding pairs
let rebuilt: String = json_remove_key(obj, key)
return json_append_key(rebuilt, esc_key, esc_val)
}
}
}
// Key does not exist append it
return json_append_key(obj, esc_key, esc_val)
}
// json_remove_key removes a key/value pair from a flat JSON object string.
// Returns the object without that key. Used internally by json_set_key.
fn json_remove_key(obj: String, key: String) -> String {
// Parse pairs manually and rebuild excluding the target key
// Trim outer braces
let inner: String = str_trim(obj)
let inner_len: Int = str_len(inner)
if inner_len < 2 { return "{}" }
// Remove leading { and trailing }
let body: String = str_slice(inner, 1, inner_len - 1)
let body_trimmed: String = str_trim(body)
if str_eq(body_trimmed, "") { return "{}" }
// Split on comma note: this is a simple split that works for flat objects
// with no nested commas (vessel names and versions have no commas)
let pairs: [String] = str_split(body_trimmed, ",")
let pair_count: Int = native_list_len(pairs)
let result: String = "{"
let added: Int = 0
let i: Int = 0
while i < pair_count {
let pair: String = str_trim(native_list_get(pairs, i))
// Each pair looks like "key":"value"
// Extract the key portion
let colon_pos: Int = str_index_of(pair, ":")
if colon_pos > 0 {
let raw_k: String = str_trim(str_slice(pair, 0, colon_pos))
// raw_k is "key" strip quotes
let k_len: Int = str_len(raw_k)
let k_unquoted: String = raw_k
if str_starts_with(raw_k, "\"") {
let k_unquoted = str_slice(raw_k, 1, k_len - 1)
}
if !str_eq(k_unquoted, key) {
if added == 0 {
let result = result + pair
} else {
let result = result + "," + pair
}
let added = added + 1
}
}
let i = i + 1
}
return result + "}"
}
// json_append_key appends an already-escaped key/value pair to a JSON object.
fn json_append_key(obj: String, esc_key: String, esc_val: String) -> String {
let pair: String = "\"" + esc_key + "\":\"" + esc_val + "\""
let inner: String = str_trim(obj)
let inner_len: Int = str_len(inner)
if inner_len < 2 { return "{" + pair + "}" }
// Remove trailing }
let body: String = str_slice(inner, 0, inner_len - 1)
let body_trimmed: String = str_trim(body)
// Remove leading {
let body2: String = str_slice(body_trimmed, 1, str_len(body_trimmed))
let body2_trimmed: String = str_trim(body2)
if str_eq(body2_trimmed, "") {
return "{" + pair + "}"
}
return "{" + body2_trimmed + "," + pair + "}"
}
// record_installed updates installed.json to record name -> version.
fn record_installed(name: String, version: String) -> Void {
let mk_ret: Int = exec_command("mkdir -p " + packages_dir())
let installed: String = read_installed()
let updated: String = json_set_key(installed, name, version)
write_installed(updated)
}
// List helpers
// list_contains returns true if item appears in lst.
fn list_contains(lst: [String], item: String) -> Bool {
let n: Int = native_list_len(lst)
let i: Int = 0
while i < n {
let v: String = native_list_get(lst, i)
if str_eq(v, item) { return true }
let i = i + 1
}
return false
}
// Dependency graph walker
// resolve_deps_recursive fetches a vessel's metadata from the registry and
// walks its dependency list depth-first, building a topological order.
//
// Parameters:
// name vessel name to resolve
// version version string (may be "" for latest)
// chain names currently on the recursion stack (cycle detection)
// visited names already appended to order (dedup)
// order accumulated result (list of "name:version" strings)
//
// Returns a Map with keys "visited", "order", and "ok" (Bool as String).
fn resolve_deps_recursive(name: String, version: String, chain: [String], visited: [String], order: [String]) -> Map<String, Any> {
let key: String = name + ":" + version
// Skip if already ordered
if list_contains(visited, key) {
return { "visited": visited, "order": order, "ok": "true" }
}
// Cycle detection: if name is already in the call chain, it's a cycle
if list_contains(chain, name) {
println("epm: error: dependency cycle detected involving '" + name + "'")
return { "visited": visited, "order": order, "ok": "false" }
}
// Push this name onto the cycle-detection chain
let chain = native_list_append(chain, name)
// Fetch vessel metadata from the registry
let content: String = registry_find(name, version)
if str_eq(content, "") {
println("epm: error: vessel '" + name + "' version '" + version + "' not found in registry")
return { "visited": visited, "order": order, "ok": "false" }
}
// content is a JSON blob: {"name":..., "version":..., "description":..., "download_url":...}
let actual_version: String = json_get_string(content, "version")
let actual_key: String = name + ":" + actual_version
// registry_find returns top-level metadata only; deps are not included in
// the Gitea-backed registry response (they live in the release body comment).
// For now we skip transitive dep resolution install only direct deps.
// Append this vessel (after all its deps would go)
let visited = native_list_append(visited, actual_key)
let order = native_list_append(order, actual_key)
return { "visited": visited, "order": order, "ok": "true" }
}
// Install order
// resolve_install_order reads manifest.el from the current directory and
// returns a list of "name:version" strings in topological install order.
//
// Returns an empty list on failure (error is printed to stdout).
fn resolve_install_order() -> [String] {
let src: String = fs_read("manifest.el")
if str_eq(src, "") {
println("epm: no manifest.el found in current directory")
return native_list_empty()
}
let deps_json: String = manifest_deps(src)
let dep_count: Int = json_array_len(deps_json)
if dep_count == 0 {
println("epm: no dependencies declared in manifest.el")
return native_list_empty()
}
let chain: [String] = native_list_empty()
let visited: [String] = native_list_empty()
let order: [String] = native_list_empty()
let i: Int = 0
while i < dep_count {
let dep: String = json_array_get(deps_json, i)
let dep_name: String = json_get_string(dep, "name")
let dep_ver: String = json_get_string(dep, "version")
let r = resolve_deps_recursive(dep_name, dep_ver, chain, visited, order)
let ok_str: String = r["ok"]
if str_eq(ok_str, "false") {
return native_list_empty()
}
let visited = r["visited"]
let order = r["order"]
let i = i + 1
}
return order
}
// Installation
// install_vessel downloads and extracts a vessel to ~/.el/packages/<name>/<version>/.
// Returns true on success.
fn install_vessel(name: String, version: String) -> Bool {
// Resolve version if not specified
let ver: String = version
if str_eq(ver, "") {
let ver = registry_latest_version(name)
if str_eq(ver, "") {
println("epm: error: no releases found for vessel '" + name + "'")
return false
}
}
// Check if already installed
let current: String = installed_version(name)
if str_eq(current, ver) {
println("epm: " + name + " " + ver + " is already installed")
return true
}
// Fetch metadata from registry
let content: String = registry_find(name, ver)
if str_eq(content, "") {
println("epm: error: cannot fetch '" + name + "' '" + ver + "' from registry")
return false
}
let download_url: String = json_get_string(content, "download_url")
if str_eq(download_url, "") {
println("epm: error: no download URL for '" + name + "' '" + ver + "'")
return false
}
// Create install directory
let install_dir: String = packages_dir() + "/" + name + "/" + ver
let mkdir_ret: Int = exec_command("mkdir -p " + install_dir)
// Download tarball to /tmp
let tmp: String = "/tmp/epm-" + name + "-" + ver + ".tar.gz"
let token: String = registry_token()
let dl_ret: Int = 0
if str_eq(token, "") {
let dl_ret = exec_command("curl -sL -o " + tmp + " \"" + download_url + "\"")
} else {
let dl_ret = exec_command("curl -sL -H \"Authorization: token " + token + "\" -o " + tmp + " \"" + download_url + "\"")
}
if dl_ret != 0 {
println("epm: error: download failed for '" + name + "' '" + ver + "' (exit " + native_int_to_str(dl_ret) + ")")
return false
}
// Extract tarball
let extract_ret: Int = exec_command("tar -xzf " + tmp + " -C " + install_dir + " --strip-components=1")
if extract_ret != 0 {
println("epm: error: extraction failed for '" + name + "' '" + ver + "' (exit " + native_int_to_str(extract_ret) + ")")
let rm_ret: Int = exec_command("rm -f " + tmp)
return false
}
// Remove temp file
let rm_ret: Int = exec_command("rm -f " + tmp)
// Record installation
record_installed(name, ver)
println("epm: installed " + name + " " + ver + " -> " + install_dir)
return true
}
// Top-level install
// cmd_install resolves and installs all dependencies for the current project.
fn cmd_install() -> Void {
let src: String = fs_read("manifest.el")
if str_eq(src, "") {
println("epm: no manifest.el found in current directory")
exit(1)
}
let pkg_name: String = manifest_name(src)
println("epm: resolving dependencies for " + pkg_name)
let order: [String] = resolve_install_order()
let n: Int = native_list_len(order)
if n == 0 {
println("epm: nothing to install")
return
}
println("epm: install order (" + native_int_to_str(n) + " vessels):")
let i: Int = 0
while i < n {
let entry: String = native_list_get(order, i)
println(" " + entry)
let i = i + 1
}
// Ensure packages directory exists
let mk2: Int = exec_command("mkdir -p " + packages_dir())
// Install each vessel in topological order
let j: Int = 0
while j < n {
let kv: String = native_list_get(order, j)
// Split "name:version" on the first colon
let colon: Int = str_index_of(kv, ":")
if colon < 0 {
println("epm: error: malformed order entry: " + kv)
exit(1)
}
let vname: String = str_slice(kv, 0, colon)
let vver: String = str_slice(kv, colon + 1, str_len(kv))
let ok: Bool = install_vessel(vname, vver)
if !ok {
println("epm: install failed at " + kv)
exit(1)
}
let j = j + 1
}
println("epm: all dependencies installed")
}