a6d093536a
El SDK CI - dev / build-and-test (pull_request) Failing after 7s
- 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
407 lines
16 KiB
EmacsLisp
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")
|
|
}
|