diff --git a/.gitea/workflows/ci-dev.yaml b/.gitea/workflows/ci-dev.yaml index df404f6..7106c5f 100644 --- a/.gitea/workflows/ci-dev.yaml +++ b/.gitea/workflows/ci-dev.yaml @@ -1,4 +1,4 @@ -name: El SDK CI — dev +name: El SDK CI - dev on: push: @@ -27,18 +27,21 @@ jobs: # Gen2: compile the bootstrap C source into a working elc binary - name: Build elc from bootstrap (gen2) run: | + # -Wl,--allow-multiple-definition: elc-bootstrap.c and el_runtime.c both define + # is_digit/is_whitespace; bootstrap predates the text-processing primitives commit gcc -O2 \ -I el-compiler/runtime \ dist/elc-bootstrap.c \ el-compiler/runtime/el_runtime.c \ - -lcurl -lpthread \ + -lcurl -lpthread -lm \ + -Wl,--allow-multiple-definition \ -o dist/elc-gen2 chmod +x dist/elc-gen2 echo "gen2 elc built" dist/elc-gen2 --version || true # Gen3: use gen2 to compile the El compiler from its own El source (self-host) - - name: Self-host: compile El compiler with gen2 (gen3) + - name: Self-host compile El compiler with gen2 (gen3) run: | mkdir -p dist/platform dist/elc-gen2 el-compiler/src/compiler.el > dist/elc-gen3.c @@ -46,38 +49,38 @@ jobs: -I el-compiler/runtime \ dist/elc-gen3.c \ el-compiler/runtime/el_runtime.c \ - -lcurl -lpthread \ + -lcurl -lpthread -lm \ -o dist/platform/elc chmod +x dist/platform/elc echo "gen3 (self-hosted) elc built" dist/platform/elc --version || true - - name: Run tests — text + - name: Run tests - text run: | ELC="$(pwd)/dist/platform/elc" \ EL_HOME="$(pwd)" \ bash tests/text/run.sh - - name: Run tests — calendar + - name: Run tests - calendar run: | ELC="$(pwd)/dist/platform/elc" \ EL_HOME="$(pwd)" \ bash tests/calendar/run.sh - - name: Run tests — time + - name: Run tests - time run: | ELC="$(pwd)/dist/platform/elc" \ EL_HOME="$(pwd)" \ bash tests/time/run.sh - - name: Run tests — html_sanitizer + - name: Run tests - html_sanitizer run: | ELC="$(pwd)/dist/platform/elc" \ EL_HOME="$(pwd)" \ bash tests/html_sanitizer/run.sh # Native El test suites (elc --test, compile-link-run) - - name: Run tests — native (core) + - name: Run tests - native (core) run: | set -euo pipefail ELC="$(pwd)/dist/platform/elc" @@ -87,7 +90,7 @@ jobs: -lcurl -lpthread -lm -o /tmp/el_native_core /tmp/el_native_core - - name: Run tests — native (text) + - name: Run tests - native (text) run: | set -euo pipefail ELC="$(pwd)/dist/platform/elc" @@ -97,7 +100,7 @@ jobs: -lcurl -lpthread -lm -o /tmp/el_native_text /tmp/el_native_text - - name: Run tests — native (string) + - name: Run tests - native (string) run: | set -euo pipefail ELC="$(pwd)/dist/platform/elc" @@ -107,7 +110,7 @@ jobs: -lcurl -lpthread -lm -o /tmp/el_native_string /tmp/el_native_string - - name: Run tests — native (math) + - name: Run tests - native (math) run: | set -euo pipefail ELC="$(pwd)/dist/platform/elc" @@ -117,7 +120,7 @@ jobs: -lcurl -lpthread -lm -o /tmp/el_native_math /tmp/el_native_math - - name: Run tests — native (state) + - name: Run tests - native (state) run: | set -euo pipefail ELC="$(pwd)/dist/platform/elc" @@ -127,7 +130,7 @@ jobs: -lcurl -lpthread -lm -o /tmp/el_native_state /tmp/el_native_state - - name: Run tests — native (time) + - name: Run tests - native (time) run: | set -euo pipefail ELC="$(pwd)/dist/platform/elc" @@ -137,7 +140,7 @@ jobs: -lcurl -lpthread -lm -o /tmp/el_native_time /tmp/el_native_time - - name: Run tests — native (json) + - name: Run tests - native (json) run: | set -euo pipefail ELC="$(pwd)/dist/platform/elc" @@ -147,7 +150,7 @@ jobs: -lcurl -lpthread -lm -o /tmp/el_native_json /tmp/el_native_json - - name: Run tests — native (env) + - name: Run tests - native (env) run: | set -euo pipefail ELC="$(pwd)/dist/platform/elc" @@ -157,7 +160,7 @@ jobs: -lcurl -lpthread -lm -o /tmp/el_native_env /tmp/el_native_env - - name: Run tests — native (fs) + - name: Run tests - native (fs) run: | set -euo pipefail ELC="$(pwd)/dist/platform/elc" diff --git a/epm/src/epm.el b/epm/src/epm.el index efe0beb..97581d9 100644 --- a/epm/src/epm.el +++ b/epm/src/epm.el @@ -1,31 +1,42 @@ // epm/src/epm.el — El Package Manager entry point // -// epm manages vessels: the deployable, versioned units of El code. -// Vessels are stored in Engram (the graph database) and installed locally -// under .epm/vessels/. +// epm manages vessels: versioned El source packages backed by Gitea releases. +// Each vessel corresponds to a Gitea repository under a shared org. Versions +// are Gitea release tags; source archives are attached as release assets named +// "-.tar.gz". +// +// Vessels are installed to ~/.el/packages///. +// The installed registry lives at ~/.el/packages/installed.json. // // Usage: // epm publish # publish current project to registry // epm install # install all dependencies from manifest.el -// epm install # install a specific vessel -// epm list # list all vessels in the registry +// epm install # install a specific vessel (latest) +// epm install @ # install a specific vessel at a version +// epm list # list installed vessels // epm info # show metadata for a vessel // epm info # show metadata for a specific version +// epm update # update all installed vessels +// epm update # update a specific vessel +// epm check # check for available updates (no install) // // Configuration (environment): -// ENGRAM_URL — Engram base URL (default: http://localhost:8742) +// EPM_GITEA_API — Gitea API base URL (default: https://git.neuralplatform.ai/api/v1) +// EPM_REGISTRY_ORG — org hosting vessel repos (default: neuron-technologies) +// EPM_TOKEN — Gitea personal access token (required for publish) // -// Import order: manifest.el, registry.el, install.el must be compiled first. -// This file imports all three and is the build entry point. +// Import order: manifest.el, registry.el, install.el, update.el must be +// compiled first. This file imports all four and is the build entry point. import "manifest.el" import "registry.el" import "install.el" +import "update.el" // ── Subcommand handlers ─────────────────────────────────────────────────────── // cmd_publish reads manifest.el from the current directory and publishes -// the vessel/package to the Engram registry. +// the vessel to the Gitea registry as a release. fn cmd_publish() -> Void { let src: String = fs_read("manifest.el") if str_eq(src, "") { @@ -51,13 +62,13 @@ fn cmd_publish() -> Void { println("epm: publishing " + name + " " + version + " ...") - let node_id: String = registry_publish(name, version, description, entry, deps_json) - if str_eq(node_id, "") { + let release_id: String = registry_publish(name, version, description, entry, deps_json) + if str_eq(release_id, "") { println("epm: publish failed") exit(1) } - println("epm: published " + name + " " + version + " (node: " + node_id + ")") + println("epm: published " + name + " " + version + " (release: " + release_id + ")") } // cmd_install_named installs a specific vessel by name (with optional version). @@ -74,41 +85,73 @@ fn cmd_install_named(spec: String) -> Void { println("epm: installing " + vname + " ...") - let mk1: Int = exec_command("mkdir -p .epm/vessels") let ok: Bool = install_vessel(vname, vver) if !ok { println("epm: install failed") exit(1) } + + // Notify about other available updates after a successful install + let outdated: Int = check_updates() + if outdated > 0 { + println("") + println(" note: " + native_int_to_str(outdated) + " vessel(s) have newer versions available — run 'epm update' to upgrade") + } } -// cmd_list prints all vessels registered in Engram. +// cmd_list prints all locally installed vessels. fn cmd_list() -> Void { - println("epm: fetching vessel list from registry ...") - let raw: String = registry_list() - let n: Int = json_array_len(raw) - - if n == 0 { - println("epm: no vessels found in registry") + let installed: String = read_installed() + if str_eq(installed, "") { + println("epm: no vessels installed") + return + } + if str_eq(installed, "{}") { + println("epm: no vessels installed") return } - println("epm: " + native_int_to_str(n) + " vessel(s) in registry:") + let inner_len: Int = str_len(installed) + if inner_len < 2 { + println("epm: no vessels installed") + return + } + let body: String = str_slice(installed, 1, inner_len - 1) + let body_trimmed: String = str_trim(body) + if str_eq(body_trimmed, "") { + println("epm: no vessels installed") + return + } + + println("epm: installed vessels:") println("") + let pairs: [String] = str_split(body_trimmed, ",") + let pair_count: Int = native_list_len(pairs) let i: Int = 0 - while i < n { - // Each element is a quoted+escaped content JSON blob - let elem: String = json_array_get_string(raw, i) - let vname: String = json_get_string(elem, "name") - let vver: String = json_get_string(elem, "version") - let vdesc: String = json_get_string(elem, "description") - println(" " + vname + " " + vver + " — " + vdesc) + while i < pair_count { + let pair: String = str_trim(native_list_get(pairs, i)) + let colon: Int = str_index_of(pair, ":") + if colon > 0 { + let raw_key: String = str_trim(str_slice(pair, 0, colon)) + let raw_val: String = str_trim(str_slice(pair, colon + 1, str_len(pair))) + let key_len: Int = str_len(raw_key) + let val_len: Int = str_len(raw_val) + let vname2: String = raw_key + let vver2: String = raw_val + if str_starts_with(raw_key, "\"") { + let vname2 = str_slice(raw_key, 1, key_len - 1) + } + if str_starts_with(raw_val, "\"") { + let vver2 = str_slice(raw_val, 1, val_len - 1) + } + println(" " + vname2 + " " + vver2) + } let i = i + 1 } } -// cmd_info prints detailed metadata for a specific vessel. +// cmd_info prints detailed metadata for a specific vessel from the registry. // argv is the full args list; idx is the position of the vessel name. fn cmd_info(argv: [String], idx: Int) -> Void { let argc: Int = native_list_len(argv) @@ -132,27 +175,18 @@ fn cmd_info(argv: [String], idx: Int) -> Void { let name: String = json_get_string(content, "name") let ver: String = json_get_string(content, "version") let desc: String = json_get_string(content, "description") - let entry: String = json_get_string(content, "entry") - let deps_raw: String = json_get_raw(content, "deps") + let dl_url: String = json_get_string(content, "download_url") println("name: " + name) println("version: " + ver) println("description: " + desc) - println("entry: " + entry) + println("download: " + dl_url) - let dep_n: Int = json_array_len(deps_raw) - if dep_n == 0 { - println("dependencies: (none)") + let local_ver: String = installed_version(name) + if !str_eq(local_ver, "") { + println("installed: " + local_ver) } else { - println("dependencies:") - let di: Int = 0 - while di < dep_n { - let dep: String = json_array_get(deps_raw, di) - let dep_name: String = json_get_string(dep, "name") - let dep_ver: String = json_get_string(dep, "version") - println(" " + dep_name + " " + dep_ver) - let di = di + 1 - } + println("installed: (not installed)") } } @@ -167,16 +201,21 @@ fn main() -> Void { println("epm — El Package Manager") println("") println("usage:") - println(" epm publish publish current project to registry") - println(" epm install install all dependencies (from manifest.el)") - println(" epm install install a specific vessel") - println(" epm install @ install a specific vessel at a version") - println(" epm list list all vessels in the registry") - println(" epm info show vessel metadata") - println(" epm info show metadata for a specific version") + println(" epm publish publish current project to registry") + println(" epm install install all dependencies (from manifest.el)") + println(" epm install install a specific vessel (latest version)") + println(" epm install @ install a specific vessel at a version") + println(" epm list list installed vessels") + println(" epm info show vessel metadata from registry") + println(" epm info show metadata for a specific version") + println(" epm update update all installed vessels") + println(" epm update update a specific vessel") + println(" epm check check for available updates (no install)") println("") println("configuration:") - println(" ENGRAM_URL Engram base URL (default: http://localhost:8742)") + println(" EPM_GITEA_API Gitea API base URL (default: https://git.neuralplatform.ai/api/v1)") + println(" EPM_REGISTRY_ORG org hosting vessel repos (default: neuron-technologies)") + println(" EPM_TOKEN Gitea personal access token (required for publish)") exit(0) } @@ -193,6 +232,12 @@ fn main() -> Void { cmd_install_named(spec) } else { cmd_install() + // Notify about other available updates after a successful install + let outdated: Int = check_updates() + if outdated > 0 { + println("") + println(" note: " + native_int_to_str(outdated) + " vessel(s) have newer versions available — run 'epm update' to upgrade") + } } return } @@ -207,6 +252,28 @@ fn main() -> Void { return } + if str_eq(sub, "update") { + if argc >= 3 { + let vessel_arg: String = native_list_get(argv, 2) + cmd_update(vessel_arg) + } else { + cmd_update("") + } + return + } + + if str_eq(sub, "check") { + println("epm: checking for updates ...") + let outdated: Int = check_updates() + if outdated == 0 { + println("epm: all installed vessels are up to date") + } else { + println("") + println("epm: " + native_int_to_str(outdated) + " vessel(s) have newer versions available — run 'epm update' to upgrade") + } + return + } + println("epm: unknown subcommand '" + sub + "'") println("run 'epm' with no arguments for usage") exit(1) diff --git a/epm/src/install.el b/epm/src/install.el index 50a3f1e..882fc90 100644 --- a/epm/src/install.el +++ b/epm/src/install.el @@ -1,22 +1,175 @@ // 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 Engram, detects cycles, and produces a -// topological install order (dependencies before dependents). +// by fetching each dep from the Gitea registry, detects cycles, and produces +// a topological install order (dependencies before dependents). // -// Installed vessels land in: .epm/vessels// +// Installed vessels land in: ~/.el/packages/// +// 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 manifest from Engram +// 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 -// ── List helpers (operating on String lists) ────────────────────────────────── +// ── 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 { @@ -32,12 +185,12 @@ fn list_contains(lst: [String], item: String) -> Bool { // ── Dependency graph walker ─────────────────────────────────────────────────── -// resolve_deps_recursive fetches a vessel's metadata from Engram and walks -// its dependency list depth-first, building a topological order. +// 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 any) +// 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) @@ -60,38 +213,22 @@ fn resolve_deps_recursive(name: String, version: String, chain: [String], visite // Push this name onto the cycle-detection chain let chain = native_list_append(chain, name) - // Fetch vessel metadata from Engram + // 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":..., "deps":[...]} + // 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 - let deps_json: String = json_get_raw(content, "deps") - // Walk this vessel's dependencies first (depth-first) - let dep_count: Int = json_array_len(deps_json) - let di: Int = 0 - while di < dep_count { - let dep: String = json_array_get(deps_json, di) - let dep_name: String = json_get_string(dep, "name") - let dep_ver: String = json_get_string(dep, "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. - 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 r - } - let visited = r["visited"] - let order = r["order"] - - let di = di + 1 - } - - // Now append this vessel (after all its 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) @@ -144,29 +281,72 @@ fn resolve_install_order() -> [String] { // ── Installation ────────────────────────────────────────────────────────────── -// install_vessel fetches a vessel's source from Engram and writes it -// to .epm/vessels//. -// -// Currently writes the content JSON blob as a manifest record so that -// other tools can introspect what is installed. When Engram stores actual -// source archives, this function would extract them. -// +// install_vessel downloads and extracts a vessel to ~/.el/packages///. // Returns true on success. fn install_vessel(name: String, version: String) -> Bool { - let content: String = registry_find(name, version) + // 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 + "' '" + version + "' from registry") + println("epm: error: cannot fetch '" + name + "' '" + ver + "' from registry") return false } - let install_dir: String = ".epm/vessels/" + name + 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) - // Write the content blob as installed.json so downstream tools know what's there - let dest: String = install_dir + "/installed.json" - fs_write(dest, content) + // 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 + } - println("epm: installed " + name + " " + version + " -> " + dest) + // 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 } @@ -199,21 +379,21 @@ fn cmd_install() -> Void { let i = i + 1 } - // Ensure .epm directory exists - let mk2: Int = exec_command("mkdir -p .epm/vessels") + // 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 last colon + // 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 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) diff --git a/epm/src/registry.el b/epm/src/registry.el index 5d7bbd6..8e1b2b5 100644 --- a/epm/src/registry.el +++ b/epm/src/registry.el @@ -1,26 +1,25 @@ -// epm/src/registry.el — Engram-backed vessel registry +// epm/src/registry.el — Gitea-backed vessel registry // -// Vessels are stored in Engram as nodes with a structured label scheme: +// Vessels are stored as Gitea repositories under a shared org: // -// label: "vessel::" (used as the search key) -// content: JSON blob with full vessel metadata -// node_type: "Entity" -// salience: 0.9 (vessels are high-salience, long-lived knowledge) +// repo: / at https://git.neuralplatform.ai +// versions: Gitea release tags (e.g. "0.1.0") +// archive: release asset named "-.tar.gz" // -// All registry operations go over HTTP to Engram. The Engram URL is read -// from the ENGRAM_URL environment variable; defaults to http://localhost:8742. +// All registry read operations use unauthenticated HTTP GET. +// registry_publish requires EPM_TOKEN to be set. // -// Endpoints used: -// POST /api/nodes — publish a vessel node -// GET /api/search?q=... — find vessels by label prefix +// Configuration (environment variables): +// EPM_GITEA_API — Gitea API base URL (default: https://git.neuralplatform.ai/api/v1) +// EPM_REGISTRY_ORG — org name that hosts vessel repos (default: neuron-technologies) +// EPM_TOKEN — Gitea personal access token (required for publish) -// ── Engram URL ──────────────────────────────────────────────────────────────── +// ── Config helpers ──────────────────────────────────────────────────────────── -// registry_url returns the base URL for Engram, with no trailing slash. -fn registry_url() -> String { - let u: String = config("ENGRAM_URL") - if str_eq(u, "") { return "http://localhost:8742" } - // Strip trailing slash if present +// registry_api_url returns the Gitea API base URL with no trailing slash. +fn registry_api_url() -> String { + let u: String = config("EPM_GITEA_API") + if str_eq(u, "") { return "https://git.neuralplatform.ai/api/v1" } let n: Int = str_len(u) if str_ends_with(u, "/") { return str_slice(u, 0, n - 1) @@ -28,143 +27,212 @@ fn registry_url() -> String { return u } +// registry_org returns the Gitea org that hosts vessel repos. +fn registry_org() -> String { + let o: String = config("EPM_REGISTRY_ORG") + if str_eq(o, "") { return "neuron-technologies" } + return o +} + +// registry_token returns the Gitea auth token for publish operations. +fn registry_token() -> String { + return config("EPM_TOKEN") +} + +// ── Release lookups ─────────────────────────────────────────────────────────── + +// registry_find_release fetches a specific release by tag from Gitea. +// GET /api/v1/repos///releases/tags/ +// Returns the raw release JSON object, or "" if not found / on error. +fn registry_find_release(name: String, version: String) -> String { + let url: String = registry_api_url() + "/repos/" + registry_org() + "/" + name + "/releases/tags/" + version + let token: String = registry_token() + let resp: String = "" + if str_eq(token, "") { + let resp = http_get(url) + } else { + let headers: String = "Authorization: token " + token + let resp = http_get_with_headers(url, headers) + } + if str_eq(resp, "") { return "" } + // Gitea returns 404 as an error JSON or empty — check for "id" field + let release_id: String = json_get_string(resp, "id") + if str_eq(release_id, "") { return "" } + return resp +} + +// registry_list_releases fetches all releases for a vessel from Gitea. +// GET /api/v1/repos///releases +// Returns a JSON array of release objects, or "[]" on error. +fn registry_list_releases(name: String) -> String { + let url: String = registry_api_url() + "/repos/" + registry_org() + "/" + name + "/releases?limit=50" + let token: String = registry_token() + let resp: String = "" + if str_eq(token, "") { + let resp = http_get(url) + } else { + let headers: String = "Authorization: token " + token + let resp = http_get_with_headers(url, headers) + } + if str_eq(resp, "") { return "[]" } + // Verify it is an array + let count: Int = json_array_len(resp) + if count < 0 { return "[]" } + return resp +} + +// registry_latest_version returns the tag_name of the most recent release. +// Gitea returns releases sorted by creation date (newest first). +// Returns "" if there are no releases or the repo is missing. +fn registry_latest_version(name: String) -> String { + let releases: String = registry_list_releases(name) + let count: Int = json_array_len(releases) + if count == 0 { return "" } + let first: String = json_array_get(releases, 0) + return json_get_string(first, "tag_name") +} + +// registry_find_asset_url scans a release JSON object for the source tarball. +// Looks for an asset named "-.tar.gz". +// Falls back to zipball_url if no matching asset is found. +// Returns "" if the release JSON is empty. +fn registry_find_asset_url(release_json: String, name: String, version: String) -> String { + if str_eq(release_json, "") { return "" } + + let target_asset: String = name + "-" + version + ".tar.gz" + let assets_json: String = json_get_raw(release_json, "assets") + let asset_count: Int = json_array_len(assets_json) + + let i: Int = 0 + while i < asset_count { + let asset: String = json_array_get(assets_json, i) + let asset_name: String = json_get_string(asset, "name") + if str_eq(asset_name, target_asset) { + return json_get_string(asset, "browser_download_url") + } + let i = i + 1 + } + + // Fall back to zipball_url from the release + let zipball: String = json_get_string(release_json, "zipball_url") + return zipball +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +// registry_find looks up a vessel in the Gitea registry. +// When version is "" the latest release is used. +// +// Returns a metadata JSON blob with keys: +// name, version, description, download_url +// Returns "" if the vessel or version is not found. +fn registry_find(name: String, version: String) -> String { + let ver: String = version + if str_eq(ver, "") { + let ver = registry_latest_version(name) + if str_eq(ver, "") { + return "" + } + } + + let release_json: String = registry_find_release(name, ver) + if str_eq(release_json, "") { return "" } + + let description: String = json_get_string(release_json, "body") + let download_url: String = registry_find_asset_url(release_json, name, ver) + + let esc_name: String = json_escape_string(name) + let esc_ver: String = json_escape_string(ver) + let esc_desc: String = json_escape_string(description) + let esc_url: String = json_escape_string(download_url) + + return "{\"name\":\"" + esc_name + "\",\"version\":\"" + esc_ver + "\",\"description\":\"" + esc_desc + "\",\"download_url\":\"" + esc_url + "\"}" +} + +// registry_list returns a JSON array of installed vessel metadata blobs. +// Listing all org repos requires auth in many Gitea setups, so this +// falls back to reading the local installed.json. +fn registry_list() -> String { + let installed: String = read_installed() + // installed is a JSON object: {"name":"version",...} + // We return an empty array — callers should use installed_version() per name + if str_eq(installed, "") { return "[]" } + if str_eq(installed, "{}") { return "[]" } + return "[]" +} + // ── Publish ─────────────────────────────────────────────────────────────────── -// registry_publish stores a vessel node in Engram. +// registry_publish creates a Gitea release and uploads the source tarball. // -// Parameters: -// name — vessel name (e.g. "el-auth") -// version — semver string (e.g. "0.1.0") -// description — human-readable description -// entry — build entry file path -// deps_json — JSON array of dep objects (from manifest_deps) +// Steps: +// 1. Build a tarball of the current directory (excluding .epm/, .git/) +// 2. POST /repos///releases to create the release +// 3. POST /repos///releases//assets to upload the tarball // -// Returns the created node ID on success, "" on failure. +// Returns the release ID string on success, "" on failure. fn registry_publish(name: String, version: String, description: String, entry: String, deps_json: String) -> String { - let label: String = "vessel:" + name + ":" + version + let token: String = registry_token() + if str_eq(token, "") { + println("epm: error: EPM_TOKEN is required for publishing") + return "" + } - // Build the content JSON — all vessel metadata in one blob - let esc_name: String = json_escape_string(name) - let esc_ver: String = json_escape_string(version) + let tarball: String = "/tmp/epm-publish-" + name + "-" + version + ".tar.gz" + let asset_name: String = name + "-" + version + ".tar.gz" + + // Build the source tarball from the current directory + let tar_cmd: String = "tar -czf " + tarball + " --exclude='.epm' --exclude='.git' --exclude='*.tar.gz' ." + let tar_ret: Int = exec_command(tar_cmd) + if tar_ret != 0 { + println("epm: error: failed to create source tarball (exit " + native_int_to_str(tar_ret) + ")") + return "" + } + + // Build release body — include metadata as JSON comment let esc_desc: String = json_escape_string(description) let esc_entry: String = json_escape_string(entry) - let content_json: String = "{\"name\":\"" + esc_name + "\",\"version\":\"" + esc_ver + "\",\"description\":\"" + esc_desc + "\",\"entry\":\"" + esc_entry + "\",\"deps\":" + deps_json + "}" + let body_text: String = description + "\n\n" + let esc_body: String = json_escape_string(body_text) + let esc_tag: String = json_escape_string(version) + let esc_name_field: String = json_escape_string(name + " " + version) - // Escape the content blob for embedding in the outer JSON - let esc_label: String = json_escape_string(label) - let esc_content: String = json_escape_string(content_json) + let release_body: String = "{\"tag_name\":\"" + esc_tag + "\",\"name\":\"" + esc_name_field + "\",\"body\":\"" + esc_body + "\",\"draft\":false,\"prerelease\":false}" - let body: String = "{\"label\":\"" + esc_label + "\",\"content\":\"" + esc_content + "\",\"node_type\":\"Entity\",\"salience\":0.9}" - let url: String = registry_url() + "/api/nodes" + let api_base: String = registry_api_url() + let org: String = registry_org() + let releases_url: String = api_base + "/repos/" + org + "/" + name + "/releases" + let auth_header: String = "Authorization: token " + token - let resp: String = http_post_json(url, body) + let resp: String = http_post_json_with_headers(releases_url, auth_header, release_body) if str_eq(resp, "") { - println("epm: error: Engram unreachable at " + url) + println("epm: error: failed to create release on Gitea (no response)") return "" } - // Check for error in response - let err: String = json_get_string(resp, "error") - if !str_eq(err, "") { - println("epm: error from Engram: " + err) + let err_msg: String = json_get_string(resp, "message") + if !str_eq(err_msg, "") { + println("epm: error from Gitea: " + err_msg) return "" } - let node_id: String = json_get_string(resp, "id") - return node_id -} - -// ── Find ────────────────────────────────────────────────────────────────────── - -// registry_find searches Engram for a specific vessel by name and version. -// -// Returns the content JSON blob for the vessel, or "" if not found. -// When version is "" any version matching the name is accepted (first hit). -fn registry_find(name: String, version: String) -> String { - let query: String = "vessel:" + name - if !str_eq(version, "") { - let query = "vessel:" + name + ":" + version - } - - let enc_q: String = url_encode(query) - let url: String = registry_url() + "/api/search?q=" + enc_q + "&limit=10" - - let resp: String = http_get(url) - if str_eq(resp, "") { - println("epm: error: Engram unreachable at " + registry_url()) + let release_id: String = json_get_string(resp, "id") + if str_eq(release_id, "") { + println("epm: error: Gitea release response missing id") return "" } - // Response is a JSON array of node objects - let count: Int = json_array_len(resp) - if count == 0 { return "" } + // Upload the tarball as a release asset via curl (multipart form) + let upload_url: String = api_base + "/repos/" + org + "/" + name + "/releases/" + release_id + "/assets?name=" + asset_name + let curl_cmd: String = "curl -s -X POST -H \"Authorization: token " + token + "\" -H \"Content-Type: application/octet-stream\" --data-binary @" + tarball + " \"" + upload_url + "\"" + let upload_resp: String = exec(curl_cmd) - // Walk results to find exact match - let i: Int = 0 - while i < count { - let node: String = json_array_get(resp, i) - let node_label: String = json_get_string(node, "label") - let expected_label: String = "vessel:" + name - if !str_eq(version, "") { - let expected_label = "vessel:" + name + ":" + version - } - if str_eq(node_label, expected_label) { - // The content field is an escaped JSON blob — decode it - let raw_content: String = json_get_string(node, "content") - return raw_content - } - let i = i + 1 + // Clean up tarball + let rm_ret: Int = exec_command("rm -f " + tarball) + + if str_eq(upload_resp, "") { + println("epm: warning: asset upload returned no response (release " + release_id + " was created)") } - // No exact match; if version was unspecified return first result's content - if str_eq(version, "") { - let first: String = json_array_get(resp, 0) - let first_label: String = json_get_string(first, "label") - if str_starts_with(first_label, "vessel:" + name + ":") { - return json_get_string(first, "content") - } - } - - return "" -} - -// ── List ────────────────────────────────────────────────────────────────────── - -// registry_list returns all vessels from Engram as a JSON array. -// -// Each element in the returned array is a content JSON blob. -// Returns "[]" when no vessels are found or Engram is unreachable. -fn registry_list() -> String { - let enc_q: String = url_encode("vessel:") - let url: String = registry_url() + "/api/search?q=" + enc_q + "&limit=200" - - let resp: String = http_get(url) - if str_eq(resp, "") { - println("epm: error: Engram unreachable at " + registry_url()) - return "[]" - } - - let count: Int = json_array_len(resp) - if count == 0 { return "[]" } - - // Collect content blobs for nodes whose label starts with "vessel:" - let out: String = "[" - let added: Int = 0 - let i: Int = 0 - while i < count { - let node: String = json_array_get(resp, i) - let lbl: String = json_get_string(node, "label") - if str_starts_with(lbl, "vessel:") { - let content: String = json_get_string(node, "content") - let esc: String = json_escape_string(content) - if added == 0 { - let out = out + "\"" + esc + "\"" - } else { - let out = out + ",\"" + esc + "\"" - } - let added = added + 1 - } - let i = i + 1 - } - return out + "]" + return release_id } diff --git a/epm/src/update.el b/epm/src/update.el new file mode 100644 index 0000000..42f60a5 --- /dev/null +++ b/epm/src/update.el @@ -0,0 +1,207 @@ +// epm/src/update.el — version checking and auto-update detection +// +// Compares installed vessel versions against the latest available releases +// in the Gitea registry, and optionally upgrades them. +// +// Depends on: registry.el (registry_latest_version, registry_find), +// install.el (read_installed, install_vessel, installed_version) + +// ── Semver helpers ──────────────────────────────────────────────────────────── + +// semver_part extracts the Nth dot-separated component from a semver string. +// Returns "0" if the component is missing. +fn semver_part(ver: String, idx: Int) -> String { + let parts: [String] = str_split(ver, ".") + let n: Int = native_list_len(parts) + if idx >= n { return "0" } + let v: String = str_trim(native_list_get(parts, idx)) + if str_eq(v, "") { return "0" } + return v +} + +// semver_compare compares two semver strings a and b. +// Returns -1 if a < b, 0 if a == b, 1 if a > b. +// Compares major, then minor, then patch numerically. +fn semver_compare(a: String, b: String) -> Int { + let a_major: Int = native_str_to_int(semver_part(a, 0)) + let b_major: Int = native_str_to_int(semver_part(b, 0)) + if a_major < b_major { return -1 } + if a_major > b_major { return 1 } + + let a_minor: Int = native_str_to_int(semver_part(a, 1)) + let b_minor: Int = native_str_to_int(semver_part(b, 1)) + if a_minor < b_minor { return -1 } + if a_minor > b_minor { return 1 } + + let a_patch: Int = native_str_to_int(semver_part(a, 2)) + let b_patch: Int = native_str_to_int(semver_part(b, 2)) + if a_patch < b_patch { return -1 } + if a_patch > b_patch { return 1 } + + return 0 +} + +// semver_gt returns true if a is strictly greater than b. +fn semver_gt(a: String, b: String) -> Bool { + let cmp: Int = semver_compare(a, b) + return cmp == 1 +} + +// ── Update checks ───────────────────────────────────────────────────────────── + +// check_updates scans installed.json and checks if newer versions are available +// in the registry. Prints a notice for each outdated vessel. +// Returns the count of outdated vessels. +fn check_updates() -> Int { + let installed: String = read_installed() + if str_eq(installed, "") { return 0 } + if str_eq(installed, "{}") { return 0 } + + // installed is a flat JSON object: {"name":"version",...} + // We need to iterate over its keys. Since El has no map-iteration primitive, + // we parse the raw JSON object manually by splitting on commas. + let inner_len: Int = str_len(installed) + if inner_len < 2 { return 0 } + // Strip outer braces + let body: String = str_slice(installed, 1, inner_len - 1) + let body_trimmed: String = str_trim(body) + if str_eq(body_trimmed, "") { return 0 } + + let pairs: [String] = str_split(body_trimmed, ",") + let pair_count: Int = native_list_len(pairs) + let outdated: Int = 0 + + let i: Int = 0 + while i < pair_count { + let pair: String = str_trim(native_list_get(pairs, i)) + // pair looks like "name":"version" + let colon: Int = str_index_of(pair, ":") + if colon > 0 { + let raw_key: String = str_trim(str_slice(pair, 0, colon)) + let raw_val: String = str_trim(str_slice(pair, colon + 1, str_len(pair))) + // Strip surrounding quotes + let key_len: Int = str_len(raw_key) + let val_len: Int = str_len(raw_val) + let vessel_name: String = raw_key + let vessel_ver: String = raw_val + if str_starts_with(raw_key, "\"") { + let vessel_name = str_slice(raw_key, 1, key_len - 1) + } + if str_starts_with(raw_val, "\"") { + let vessel_ver = str_slice(raw_val, 1, val_len - 1) + } + + // Check latest version in registry + let latest: String = registry_latest_version(vessel_name) + if !str_eq(latest, "") { + if semver_gt(latest, vessel_ver) { + println(" " + vessel_name + ": " + vessel_ver + " -> " + latest + " available") + let outdated = outdated + 1 + } + } + } + let i = i + 1 + } + + return outdated +} + +// ── Update command ──────────────────────────────────────────────────────────── + +// cmd_update fetches the latest version of all installed vessels (or a specific +// one) and installs it if newer than what's currently installed. +// If vessel_name is "" it updates all installed vessels. +fn cmd_update(vessel_name: String) -> Void { + if !str_eq(vessel_name, "") { + // Update a single named vessel + let current: String = installed_version(vessel_name) + if str_eq(current, "") { + println("epm: '" + vessel_name + "' is not installed") + exit(1) + } + + let latest: String = registry_latest_version(vessel_name) + if str_eq(latest, "") { + println("epm: no releases found for '" + vessel_name + "' in registry") + return + } + + if !semver_gt(latest, current) { + println("epm: " + vessel_name + " " + current + " is already up to date") + return + } + + println("epm: updating " + vessel_name + " " + current + " -> " + latest + " ...") + let ok: Bool = install_vessel(vessel_name, latest) + if !ok { + println("epm: update failed for " + vessel_name) + exit(1) + } + return + } + + // Update all installed vessels + let installed: String = read_installed() + if str_eq(installed, "") { + println("epm: no installed vessels") + return + } + if str_eq(installed, "{}") { + println("epm: no installed vessels") + return + } + + let inner_len: Int = str_len(installed) + if inner_len < 2 { + println("epm: no installed vessels") + return + } + let body: String = str_slice(installed, 1, inner_len - 1) + let body_trimmed: String = str_trim(body) + if str_eq(body_trimmed, "") { + println("epm: no installed vessels") + return + } + + let pairs: [String] = str_split(body_trimmed, ",") + let pair_count: Int = native_list_len(pairs) + let updated_count: Int = 0 + + let i: Int = 0 + while i < pair_count { + let pair: String = str_trim(native_list_get(pairs, i)) + let colon: Int = str_index_of(pair, ":") + if colon > 0 { + let raw_key: String = str_trim(str_slice(pair, 0, colon)) + let raw_val: String = str_trim(str_slice(pair, colon + 1, str_len(pair))) + let key_len: Int = str_len(raw_key) + let val_len: Int = str_len(raw_val) + let vessel_name2: String = raw_key + let vessel_ver2: String = raw_val + if str_starts_with(raw_key, "\"") { + let vessel_name2 = str_slice(raw_key, 1, key_len - 1) + } + if str_starts_with(raw_val, "\"") { + let vessel_ver2 = str_slice(raw_val, 1, val_len - 1) + } + + let latest: String = registry_latest_version(vessel_name2) + if !str_eq(latest, "") { + if semver_gt(latest, vessel_ver2) { + println("epm: updating " + vessel_name2 + " " + vessel_ver2 + " -> " + latest + " ...") + let ok: Bool = install_vessel(vessel_name2, latest) + if ok { + let updated_count = updated_count + 1 + } + } + } + } + let i = i + 1 + } + + if updated_count == 0 { + println("epm: all installed vessels are up to date") + } else { + println("epm: updated " + native_int_to_str(updated_count) + " vessel(s)") + } +}