add epm — El Package Manager

Introduces epm/, a new component written entirely in native El.
epm manages vessels (El's deployable package format): publish to Engram,
install with full dependency resolution, list registry contents, and
inspect vessel metadata.

- epm/manifest.el         — package manifest
- epm/src/manifest.el     — vessel/package manifest parser (line-by-line,
                            same approach as elb.el)
- epm/src/registry.el     — Engram-backed vessel registry (POST /api/nodes,
                            GET /api/search); vessels stored as Entity nodes
                            with label "vessel:<name>:<version>"
- epm/src/install.el      — topological dependency resolver with cycle
                            detection; installs to .epm/vessels/<name>/
- epm/src/epm.el          — main entry point: publish / install / list / info
This commit is contained in:
Will Anderson
2026-05-04 19:31:24 -05:00
parent 0791fda43e
commit 9e8d23bcd9
5 changed files with 801 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
// epm/src/registry.el Engram-backed vessel registry
//
// Vessels are stored in Engram as nodes with a structured label scheme:
//
// label: "vessel:<name>:<version>" (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)
//
// All registry operations go over HTTP to Engram. The Engram URL is read
// from the ENGRAM_URL environment variable; defaults to http://localhost:8742.
//
// Endpoints used:
// POST /api/nodes publish a vessel node
// GET /api/search?q=... find vessels by label prefix
// Engram URL
// 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
let n: Int = str_len(u)
if str_ends_with(u, "/") {
return str_slice(u, 0, n - 1)
}
return u
}
// Publish
// registry_publish stores a vessel node in Engram.
//
// 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)
//
// Returns the created node ID 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
// 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 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 + "}"
// 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 body: String = "{\"label\":\"" + esc_label + "\",\"content\":\"" + esc_content + "\",\"node_type\":\"Entity\",\"salience\":0.9}"
let url: String = registry_url() + "/api/nodes"
let resp: String = http_post_json(url, body)
if str_eq(resp, "") {
println("epm: error: Engram unreachable at " + url)
return ""
}
// Check for error in response
let err: String = json_get_string(resp, "error")
if !str_eq(err, "") {
println("epm: error from Engram: " + err)
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())
return ""
}
// Response is a JSON array of node objects
let count: Int = json_array_len(resp)
if count == 0 { return "" }
// 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
}
// 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 + "]"
}