973 {"name":"cpm","version":"0.0.4","files":[{"path":"app.lua","offset":0,"length":809},{"path":"bin/cpm.lua","offset":809,"length":283},{"path":"bundle.lua","offset":1092,"length":3942},{"path":"commands/install.lua","offset":5034,"length":1375},{"path":"commands/list.lua","offset":6409,"length":606},{"path":"commands/remove.lua","offset":7015,"length":854},{"path":"commands/search.lua","offset":7869,"length":1113},{"path":"commands/update.lua","offset":8982,"length":800},{"path":"cpm.json","offset":9782,"length":223},{"path":"hook.lua","offset":10005,"length":1281},{"path":"install.lua","offset":11286,"length":8242},{"path":"net.lua","offset":19528,"length":2775},{"path":"registry.lua","offset":22303,"length":991},{"path":"sha256.lua","offset":23294,"length":4152},{"path":"startup.lua","offset":27446,"length":584},{"path":"state.lua","offset":28030,"length":997},{"path":"store.lua","offset":29027,"length":7001},{"path":"sync.lua","offset":36028,"length":5021}]}-- The cpm cli app, built from the shared command declarations. bin/cpm.lua runs it, -- and startup.lua builds it again at boot to register shell tab-completion. local cli = require("cli") local registry = require("cpm.registry") local app = cli.new({ name = "cpm", description = "cpm, the Chung Package Manager", footer = function() return "Registry: " .. registry.baseUrl() end, }) -- Every command module is loaded up front so `cpm update cpm` can replace the package -- tree on disk while this program is still running from it (the cli library is a single -- file for the same reason). Each module declares its interface next to its handler. for _, name in ipairs({ "install", "remove", "update", "list", "search" }) do app:command(name, require("cpm.commands." .. name)) end return app -- cpm: the Chung Package Manager client for CC:Tweaked. -- Runs through the /cpm/bin/cpm.lua shim, which prepends /cpm/packages to package.path. -- The app itself lives in cpm.app, shared with startup.lua's tab-completion registration. local app = require("cpm.app") app:run(...) -- The bundle container: "\n". -- Files are located by manifest offsets (relative to the first blob byte) and sliced out with -- string.sub, so the only parsing is a few hundred bytes of JSON regardless of package size. local sha256 = require("cpm.sha256") local FILES_PER_YIELD = 4 local bundle = {} local function yield() os.queueEvent("cpm_yield") os.pullEvent("cpm_yield") end --- Paths come from the registry, which validates them at publish, but the client re-checks --- before writing so a misbehaving registry cannot escape the package directory. function bundle.validatePath(path) if type(path) ~= "string" or path == "" then return false, "empty path" end if path:find("\\", 1, true) then return false, "backslash in path" end if path:sub(1, 1) == "/" then return false, "absolute path" end for segment in (path .. "/"):gmatch("([^/]*)/") do if segment == "" or segment == "." or segment == ".." then return false, "invalid path segment" end end return true end --- Parse the container into a list of { path, offset, length } plus the 1-based blob start. function bundle.parse(bytes) local newline = bytes:find("\n", 1, true) if not newline then return nil, "bundle is missing the manifest length line" end local header = bytes:sub(1, newline - 1) if not header:match("^%d+$") then return nil, "bundle manifest length is not a number" end local manifestLength = tonumber(header) local blobStart = newline + manifestLength + 1 if blobStart > #bytes + 1 then return nil, "bundle manifest is truncated" end local manifest = textutils.unserialiseJSON(bytes:sub(newline + 1, newline + manifestLength)) if type(manifest) ~= "table" or type(manifest.files) ~= "table" then return nil, "bundle manifest is not valid JSON" end local blobLength = #bytes - blobStart + 1 for _, file in ipairs(manifest.files) do local ok, reason = bundle.validatePath(file.path) if not ok then return nil, string.format("rejected path %s: %s", tostring(file.path), reason) end if type(file.offset) ~= "number" or type(file.length) ~= "number" or file.offset < 0 or file.length < 0 or file.offset + file.length > blobLength then return nil, "bundle entry " .. file.path .. " points outside the blob" end end return manifest.files, blobStart end --- Compare the digest of the entire response body against the registry's recorded hex digest. function bundle.verify(bytes, expectedHex) if type(expectedHex) ~= "string" or not expectedHex:match("^%x+$") then return false, "registry metadata has no usable bundle sha256" end local actual = sha256.hex(bytes) if actual ~= expectedHex:lower() then return false, string.format("sha256 mismatch (expected %s, got %s)", expectedHex, actual) end return true end local function writeBinary(path, content) local parent = fs.getDir(path) if parent ~= "" and not fs.exists(parent) then fs.makeDir(parent) end local handle, err = fs.open(path, "wb") if not handle then return false, err or ("cannot open " .. path) end handle.write(content) handle.close() return true end --- Write every file in the container under `dir`. Paths are validated by parse, so the caller --- should point `dir` at a staging directory and swap it in only after this succeeds. function bundle.extract(bytes, dir) local files, blobStart = bundle.parse(bytes) if not files then return false, blobStart end for index, file in ipairs(files) do local first = blobStart + file.offset local content = bytes:sub(first, first + file.length - 1) local ok, err = writeBinary(fs.combine(dir, file.path), content) if not ok then return false, err end if index % FILES_PER_YIELD == 0 then yield() end end return true end return bundle -- cpm install [@] ... local sync = require("cpm.sync") local state = require("cpm.state") local function parseSpec(request) local name, spec = request:match("^([^@]+)@(.+)$") if not name then name, spec = request, "latest" end if not name:match("^[%w%-%._]+$") then error("invalid package name: " .. name, 0) end return name, spec end return { description = "Install packages, recording each as a root", arguments = { { name = "packages", hint = "[@]", required = true, repeated = true }, }, handler = function(args) local current = state.load() local roots = current.roots local bare = {} for _, request in ipairs(args.packages) do local name, spec = parseSpec(request) roots[name] = spec if spec == "latest" and not request:find("@", 1, true) then bare[name] = true end end -- A bare `cpm install foo` pins a caret range on whatever "latest" resolves to, so later -- updates stay within the same major version. Pinned before the sync so an interrupted -- sync never records "latest" as the root spec. if next(bare) ~= nil then for _, pkg in ipairs(sync.resolve(roots)) do if bare[pkg.name] then roots[pkg.name] = "^" .. pkg.version end end end sync.apply(roots) end, } -- cpm list local state = require("cpm.state") return { description = "List installed packages and their versions", handler = function() local current = state.load() local names = {} for name in pairs(current.installed) do names[#names + 1] = name end if #names == 0 then print("Nothing installed") return end table.sort(names) for _, name in ipairs(names) do local spec = current.roots[name] local suffix = spec and (" (" .. spec .. ")") or " (dependency)" print(name .. "@" .. current.installed[name] .. suffix) end end, } -- cpm remove ... -- Dropping a root and re-syncing lets the registry recompute what is still reachable; state.json -- deliberately stores no dependency graph of its own. local sync = require("cpm.sync") local state = require("cpm.state") return { description = "Remove root packages and any dependencies left unused", arguments = { { name = "packages", hint = "", required = true, repeated = true }, }, handler = function(args) local current = state.load() for _, name in ipairs(args.packages) do if not current.roots[name] then if current.installed[name] then error(name .. " is a dependency of another package, remove that package instead", 0) end error(name .. " is not installed", 0) end current.roots[name] = nil end sync.apply(current.roots) end, } -- cpm search [] -- The registry has no search endpoint yet, so this filters GET /packages by name substring. local registry = require("cpm.registry") return { description = "Search the registry by package name", arguments = { { name = "query", hint = "" }, }, handler = function(args) local query = (args.query or ""):lower() local data, err = registry.get("/packages") if not data then error("search failed: " .. err, 0) end local matches = {} for _, pkg in ipairs(data.packages or {}) do if query == "" or pkg.name:lower():find(query, 1, true) then matches[#matches + 1] = pkg end end table.sort(matches, function(a, b) return a.name < b.name end) if #matches == 0 then print("No packages found") return end for _, pkg in ipairs(matches) do local latest = pkg["dist-tags"] and pkg["dist-tags"].latest local line = pkg.name .. (latest and ("@" .. latest) or "") if pkg.author then line = line .. " by " .. pkg.author end print(line) end end, } -- cpm update [ ...] -- The store holds one version per package, so an update is always a full re-resolve of every -- root; named packages only assert that the user is updating something they explicitly installed. local sync = require("cpm.sync") local state = require("cpm.state") return { description = "Re-resolve every root and apply the changes", arguments = { { name = "packages", hint = "", repeated = true }, }, handler = function(args) local current = state.load() if next(current.roots) == nil then print("Nothing installed") return end for _, name in ipairs(args.packages) do if not current.roots[name] then error(name .. " is not an installed root package", 0) end end sync.apply(current.roots) end, } { "name": "cpm", "version": "0.0.4", "description": "The cpm package manager client for CC:Tweaked computers.", "author": "chungindustries", "startup": "startup.lua", "dependencies": { "cli": "^0.0.3" } } -- The global require hook: makes /cpm/packages requireable from every program. -- -- Every program environment in CC:Tweaked flows through the global `load`, resolved at call -- time (the shell's program launcher, `loadfile` and so `os.run`, and the `lua` REPL), so -- wrapping it once lets cpm prepend its store to each fresh package.path. rawget skips a -- `package` inherited through the env's `__index = _G` chain, and prepending only when the -- prefix is absent keeps the wrapper idempotent for sandboxes that reuse a patched path. -- -- This file is the hook's only source. It is dofile()d (which runs it against _G, all it -- needs) from /startup/50_cpm.lua at boot and from store.ensureRequireHook for the running -- session; the guard makes double activation a no-op. if _G.__cpm_require_hook then return end _G.__cpm_require_hook = true local prefix = "/cpm/packages/?.lua;/cpm/packages/?/init.lua;" local native_load = _G.load _G.load = function(chunk, name, mode, env) if type(env) == "table" then local pkg = rawget(env, "package") if type(pkg) == "table" and type(pkg.path) == "string" and not pkg.path:find(prefix, 1, true) then pkg.path = prefix .. pkg.path end end return native_load(chunk, name, mode, env) end -- cpm bootstrap installer. Served by the registry at GET /install and run with: -- wget run https://registry.cpm.chungindustries.com/install -- -- Nothing is installed yet, so up to extraction this file is self-contained: it duplicates -- the minimal container parsing and path validation from cpm.bundle instead of requiring it. -- It also skips sha256 verification, trusting HTTPS for this first hop; the installed client -- verifies every bundle from then on, including its own updates via `cpm update cpm`. The -- whole resolved closure is installed, not just cpm: the client requires its dependencies -- up front, so a partial bootstrap could not even print help. Once the packages are on -- disk, setup is delegated to the cpm package's own store.lua so shims, the startup -- drop-in, and the require hook each have a single source. local DEFAULT_URL = "https://registry.cpm.chungindustries.com" local PACKAGES_DIR = "/cpm/packages" local STAGING_DIR = "/cpm/.staging" if not http then printError("The http API is disabled on this computer; cpm needs it to reach the registry") return end local baseUrl = (settings.get("cpm.registry", DEFAULT_URL)):gsub("/+$", "") local function readAll(handle) local body = handle.readAll() handle.close() return body or "" end local function fail(message) printError("cpm install failed: " .. message) end local function jsendMessage(body) local doc = textutils.unserialiseJSON(body) if type(doc) == "table" then if type(doc.data) == "table" and doc.data.message then return doc.data.message end return doc.message end end local function writeFile(path, content, mode) local parent = fs.getDir(path) if parent ~= "" and not fs.exists(parent) then fs.makeDir(parent) end local handle, err = fs.open(path, mode or "w") if not handle then error("cannot write " .. path .. ": " .. tostring(err), 0) end handle.write(content) handle.close() end local function validPath(path) if type(path) ~= "string" or path == "" or path:sub(1, 1) == "/" or path:find("\\", 1, true) then return false end for segment in (path .. "/"):gmatch("([^/]*)/") do if segment == "" or segment == "." or segment == ".." then return false end end return true end -- Ask the registry which cpm version "latest" is and for its fully pinned dependency closure. local function resolveClosure() local response, err, failed = http.post({ url = baseUrl .. "/resolve", body = textutils.serialiseJSON({ dependencies = { cpm = "latest" } }), headers = { ["Content-Type"] = "application/json", Accept = "application/json" }, }) if not response then return nil, (failed and jsendMessage(readAll(failed))) or err or "resolve request failed" end local resolved = textutils.unserialiseJSON(readAll(response)) if type(resolved) == "table" and resolved.status == "success" and type(resolved.data) == "table" and type(resolved.data.packages) == "table" then return resolved.data.packages end return nil, "registry did not resolve the cpm package" end -- Download and extract one package: staged first, swapped into place only once complete, -- so a failed write (full disk, bad bundle) never destroys a working install. local function install(pkg) local response, err, failed = http.get({ url = baseUrl .. pkg.dist.bundle.url, binary = true, headers = { ["Accept-Encoding"] = "gzip" }, }) if not response then return nil, (failed and jsendMessage(readAll(failed))) or err or "bundle download failed" end local bytes = readAll(response) -- Container: "\n", offsets relative to the blob start. local newline = bytes:find("\n", 1, true) local manifestLength = newline and tonumber(bytes:sub(1, newline - 1)) if not manifestLength then return nil, "bundle has no manifest length line" end local blobStart = newline + manifestLength + 1 local manifest = textutils.unserialiseJSON(bytes:sub(newline + 1, newline + manifestLength)) if type(manifest) ~= "table" or type(manifest.files) ~= "table" then return nil, "bundle manifest is not valid JSON" end for _, file in ipairs(manifest.files) do if not validPath(file.path) then return nil, "bundle contains an invalid path: " .. tostring(file.path) end if type(file.offset) ~= "number" or type(file.length) ~= "number" or blobStart + file.offset + file.length - 1 > #bytes then return nil, "bundle entry points outside the blob: " .. file.path end end local staging = STAGING_DIR .. "/" .. pkg.name if fs.exists(staging) then fs.delete(staging) end for index, file in ipairs(manifest.files) do local first = blobStart + file.offset writeFile(fs.combine(staging, file.path), bytes:sub(first, first + file.length - 1), "wb") if index % 4 == 0 then os.queueEvent("cpm_yield") os.pullEvent("cpm_yield") end end if not fs.exists(staging) then return nil, "bundle contained no files" end local target = PACKAGES_DIR .. "/" .. pkg.name if fs.exists(target) then fs.delete(target) end if not fs.exists(PACKAGES_DIR) then fs.makeDir(PACKAGES_DIR) end fs.move(staging, target) return true end print("Resolving cpm...") local packages, resolveErr = resolveClosure() if not packages then return fail(resolveErr) end -- Package names become install paths, so re-validate them even though the registry -- enforces the same shape at publish (defense in depth, matching the path checks). local cpmVersion for _, pkg in ipairs(packages) do if type(pkg.name) ~= "string" or not pkg.name:match("^[%w%-%._]+$") then return fail("registry returned an invalid package name: " .. tostring(pkg.name)) end if pkg.name == "cpm" then cpmVersion = pkg.version end end if not cpmVersion then return fail("registry did not resolve the cpm package") end -- Install dependencies-first, the order the registry resolves in. A failure between -- packages leaves no live package half-written; re-running the bootstrap finishes the job. for _, pkg in ipairs(packages) do print("Downloading " .. pkg.name .. "@" .. pkg.version .. "...") local ok, message = install(pkg) if not ok then return fail(message) end end -- The packages are on disk now; cpm's own store.lua (which requires nothing) does the rest: -- shims, the startup drop-in, and both live activations, from their single source. local store = assert(loadfile(PACKAGES_DIR .. "/cpm/store.lua", nil, _ENV))() local startupHooks = {} for _, pkg in ipairs(packages) do store.writeShims(pkg.name) startupHooks[#startupHooks + 1] = store.syncStartupHook(pkg.name) end store.writeStartup() -- Merged into any existing state so re-running the bootstrap never untracks other packages. local prior = {} local stateHandle = fs.open(store.STATE, "r") if stateHandle then local existing = textutils.unserialiseJSON(stateHandle.readAll() or "") stateHandle.close() if type(existing) == "table" then prior = existing end end local roots = type(prior.roots) == "table" and prior.roots or {} local installed = type(prior.installed) == "table" and prior.installed or {} -- cpm tracks "latest" rather than a caret range so `cpm update` always picks up new -- releases, including the 0.x ones a caret would exclude. roots.cpm = "latest" for _, pkg in ipairs(packages) do installed[pkg.name] = pkg.version end store.writeFile(store.STATE, textutils.serialiseJSON({ roots = roots, installed = installed })) store.ensureShellPath() store.ensureRequireHook() -- Boot runs the startup hooks written above; run them now as well so boot-time behavior -- (cpm's shell tab-completion) works in this session too. A failing hook is a warning, -- not a failed install: everything is on disk and the next boot retries it. for _, hook in ipairs(startupHooks) do local fn, loadErr = loadfile(hook, nil, _ENV) local ok, runErr if fn then ok, runErr = pcall(fn) else ok, runErr = false, loadErr end if not ok then printError("Warning: " .. hook .. " failed: " .. tostring(runErr)) end end print("Installed cpm@" .. cpmVersion .. ". Run `cpm help` to get started.") -- Shared HTTP helpers: response reading, JSend unwrapping, JSON requests, and binary -- downloads. Callers pass full URLs; knowing where the registry lives is registry.lua's job. -- -- The bootstrap installer (install.lua) deliberately does not use this module: it runs -- before anything is installed, so it keeps its own minimal copies of readAll and the -- JSend message lookup. local net = {} --- Read a response (or error response) handle to the end and close it. function net.readAll(handle) local body = handle.readAll() handle.close() return body or "" end -- JSend: success carries data, fail carries data.message, error carries a top-level message. local function unwrap(body) local doc = textutils.unserialiseJSON(body) if type(doc) ~= "table" then return nil, "server returned an unreadable response" end if doc.status == "success" then return doc.data end if type(doc.data) == "table" and type(doc.data.message) == "string" then return nil, doc.data.message end if type(doc.message) == "string" then return nil, doc.message end return nil, "request failed" end -- On failure CC hands back the error response (when there is one) as a third value, which is -- where the JSend message lives, so prefer that over the generic transport error string. local function describeFailure(err, failed) if failed then local code = failed.getResponseCode() local _, message = unwrap(net.readAll(failed)) return string.format("%s (HTTP %d)", message, code) end return err or "unknown HTTP error" end --- GET or POST `url` as JSON and return the JSend data, or nil plus a message. function net.requestJson(method, url, body) local options = { url = url, headers = { Accept = "application/json" }, } if body ~= nil then options.body = textutils.serialiseJSON(body) options.headers["Content-Type"] = "application/json" end local response, err, failed = (method == "POST" and http.post or http.get)(options) if not response then return nil, describeFailure(err, failed) end return unwrap(net.readAll(response)) end --- Download raw bytes from `url`. Gzip is requested explicitly because CC only decompresses --- what it negotiated for, and decompression happens in Java, so the bytes returned are the --- stored artifact. One retry covers the occasional dropped connection without hiding real --- failures. function net.getBytes(url) local options = { url = url, binary = true, headers = { ["Accept-Encoding"] = "gzip" }, } local lastErr for _ = 1, 2 do local response, err, failed = http.get(options) if response then return net.readAll(response) end lastErr = describeFailure(err, failed) end return nil, lastErr end return net -- The cpm registry client: base URL from settings, endpoints on top of cpm.net. local net = require("cpm.net") local DEFAULT_URL = "https://registry.cpm.chungindustries.com" settings.define("cpm.registry", { description = "Base URL of the cpm registry", default = DEFAULT_URL, type = "string", }) local registry = {} function registry.baseUrl() local url = settings.get("cpm.registry", DEFAULT_URL) return (url:gsub("/+$", "")) end --- GET `path` and return the JSend data, or nil plus a message. function registry.get(path) return net.requestJson("GET", registry.baseUrl() .. path) end --- POST `body` as JSON to `path` and return the JSend data, or nil plus a message. function registry.post(path, body) return net.requestJson("POST", registry.baseUrl() .. path, body) end --- Download a bundle container by its `dist.bundle.url` path and return the raw bytes. function registry.getBundle(path) return net.getBytes(registry.baseUrl() .. path) end return registry -- SHA-256 (FIPS 180-4) on bit32, for verifying bundle digests on CC:Tweaked. -- -- The compression function is adapted from ccryptolib's sha256.lua by Miguel Oliveira -- (https://github.com/migeyel/ccryptolib), MIT License, Copyright (c) 2023 Miguel Oliveira. -- ccryptolib's generated packing helpers are replaced with string.pack/unpack, which -- CC:Tweaked's Cobalt runtime provides, so this file is dependency free. local band, bnot, bxor = bit32.band, bit32.bnot, bit32.bxor local rrotate, rshift = bit32.rrotate, bit32.rshift local unpack = table.unpack local BLOCK_FORMAT = ">" .. ("I4"):rep(16) -- Round constants: first 32 bits of the fractional parts of the cube roots of the first 64 primes. -- stylua: ignore local K = { 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, } -- Initial hash: first 32 bits of the fractional parts of the square roots of the first 8 primes. -- stylua: ignore local H0 = { 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, } -- Sums are allowed to exceed 2^32 inside a round because bit32 reduces its arguments modulo 2^32; -- only the chaining values are reduced explicitly so they stay representable for string.format. local function compress(h, w) for j = 17, 64 do local w15 = w[j - 15] local w2 = w[j - 2] local s0 = bxor(rrotate(w15, 7), rrotate(w15, 18), rshift(w15, 3)) local s1 = bxor(rrotate(w2, 17), rrotate(w2, 19), rshift(w2, 10)) w[j] = (w[j - 16] + s0 + w[j - 7] + s1) % 2 ^ 32 end local a, b, c, d, e, f, g, hh = unpack(h) for j = 1, 64 do local S1 = bxor(rrotate(e, 6), rrotate(e, 11), rrotate(e, 25)) local ch = bxor(band(e, f), band(bnot(e), g)) local temp1 = hh + S1 + ch + K[j] + w[j] local S0 = bxor(rrotate(a, 2), rrotate(a, 13), rrotate(a, 22)) local maj = bxor(band(a, b), band(a, c), band(b, c)) local temp2 = S0 + maj hh = g g = f f = e e = (d + temp1) % 2 ^ 32 d = c c = b b = a a = (temp1 + temp2) % 2 ^ 32 end h[1] = (h[1] + a) % 2 ^ 32 h[2] = (h[2] + b) % 2 ^ 32 h[3] = (h[3] + c) % 2 ^ 32 h[4] = (h[4] + d) % 2 ^ 32 h[5] = (h[5] + e) % 2 ^ 32 h[6] = (h[6] + f) % 2 ^ 32 h[7] = (h[7] + g) % 2 ^ 32 h[8] = (h[8] + hh) % 2 ^ 32 end -- Hands control back to the CC scheduler so long hashes do not trip the yield watchdog. local function yield() os.queueEvent("cpm_yield") os.pullEvent("cpm_yield") end local sha256 = {} --- Returns the lowercase hex SHA-256 digest of `data`, yielding every `yieldEvery` bytes --- (default 8 KB) of processed input. function sha256.hex(data, yieldEvery) yieldEvery = yieldEvery or 8192 -- Pad to a multiple of 64 bytes: 0x80, zeros, then the bit length as a 64-bit big-endian -- integer split into two 32-bit halves (no reliance on 64-bit integer support). local bitlen = #data * 8 local padlen = -(#data + 9) % 64 local lengthHigh = math.floor(bitlen / 2 ^ 32) local lengthLow = bitlen % 2 ^ 32 data = data .. "\128" .. ("\0"):rep(padlen) .. string.pack(">I4I4", lengthHigh, lengthLow) local h = { unpack(H0) } local sinceYield = 0 for i = 1, #data, 64 do compress(h, { string.unpack(BLOCK_FORMAT, data, i) }) sinceYield = sinceYield + 64 if sinceYield >= yieldEvery then sinceYield = 0 yield() end end return string.format("%08x%08x%08x%08x%08x%08x%08x%08x", unpack(h)) end return sha256 -- cpm's startup hook, declared in cpm.json and run at boot by /startup/60_cpm_cpm.lua: -- registers shell tab-completion for the cpm program, generated from the same command -- declarations bin/cpm.lua runs. Completion must exist before the user types, which is -- why this runs at boot rather than in the program itself. The shell keys completers -- by resolved program path without the leading slash, so the target is the /cpm/bin -- shim as shell.resolveProgram returns it. local app = require("cpm.app") shell.setCompletionFunction("cpm/bin/cpm.lua", app:completionFunction()) -- /cpm/state.json: the roots the user asked for and the pinned set currently on disk. local store = require("cpm.store") local state = {} local function readFile(path) local handle = fs.open(path, "r") if not handle then return nil end local content = handle.readAll() handle.close() return content end --- Load the state file, falling back to empty roots and installed maps. function state.load() local loaded = {} local content = readFile(store.STATE) if content then local parsed = textutils.unserialiseJSON(content) if type(parsed) == "table" then loaded = parsed end end return { roots = type(loaded.roots) == "table" and loaded.roots or {}, installed = type(loaded.installed) == "table" and loaded.installed or {}, } end --- Persist the state file. Written last during installs so it never claims more than is on disk. function state.save(current) store.writeFile(store.STATE, textutils.serialiseJSON(current)) end return state -- The on-computer layout under /cpm: package trees, bin shims, staging swaps, boot helpers. local store = {} store.ROOT = "/cpm" store.PACKAGES = "/cpm/packages" store.BIN = "/cpm/bin" store.STAGING = "/cpm/.staging" store.STATE = "/cpm/state.json" store.STARTUP = "/startup/50_cpm.lua" -- The require hook ships as a file in the cpm package itself, so it updates with cpm. store.HOOK = "/cpm/packages/cpm/hook.lua" local PATH_PREFIX = "/cpm/packages/?.lua;/cpm/packages/?/init.lua;" local PATH_PREPEND = 'package.path = "' .. PATH_PREFIX .. '" .. package.path' function store.packageDir(name) return fs.combine(store.PACKAGES, name) end function store.stagingDir(name) return fs.combine(store.STAGING, name) end function store.writeFile(path, content) local parent = fs.getDir(path) if parent ~= "" and not fs.exists(parent) then fs.makeDir(parent) end local handle, err = fs.open(path, "w") if not handle then error("cannot write " .. path .. ": " .. tostring(err), 0) end handle.write(content) handle.close() end -- Every file under a package's bin/ becomes a program on the shell path, named by basename. local function binPrograms(name) local binDir = fs.combine(store.packageDir(name), "bin") local programs = {} if not fs.isDir(binDir) then return programs end for _, file in ipairs(fs.list(binDir)) do local base = file:match("^(.+)%.lua$") if base and not fs.isDir(fs.combine(binDir, file)) then programs[#programs + 1] = base end end return programs end local function shimPath(program) return fs.combine(store.BIN, program .. ".lua") end -- A shim's loadfile line names the package it runs, so ownership can be read back from disk. local function shimOwner(program) local handle = fs.open(shimPath(program), "r") if not handle then return nil end local source = handle.readAll() or "" handle.close() return source:match('loadfile%("/cpm/packages/([^/"]+)/bin/') end -- The shim runs the real file in the caller's globals after prepending the package path, which -- is what lets packages require their dependencies without any boilerplate of their own. local function shimSource(name, program) return table.concat({ PATH_PREPEND, string.format( 'local fn = assert(loadfile("/cpm/packages/%s/bin/%s.lua", nil, _ENV))', name, program ), "return fn(...)", "", }, "\n") end local function startupHookPath(name) return "/startup/60_cpm_" .. name .. ".lua" end -- Every bundle ships the package's own cpm.json, so the installed tree is self-describing. local function readManifest(name) local path = fs.combine(store.packageDir(name), "cpm.json") if not fs.exists(path) then return nil end local handle = fs.open(path, "r") if not handle then return nil end local content = handle.readAll() handle.close() local manifest = textutils.unserialiseJSON(content or "") if type(manifest) == "table" then return manifest end return nil end --- A package declaring `startup` in its cpm.json gets a numbered drop-in that runs that file --- at boot. It sorts after 50_cpm.lua, so /cpm/bin is already on the shell path when it runs. --- The hook is regenerated on every install and deleted when the field (or the package) goes --- away. Returns the hook path when one was written, so installers can also run it for the --- current session. function store.syncStartupHook(name) local manifest = readManifest(name) local startup = manifest and manifest.startup local hook = startupHookPath(name) if type(startup) ~= "string" or startup == "" then if fs.exists(hook) then fs.delete(hook) end return nil end store.writeFile( hook, table.concat({ PATH_PREPEND, string.format('local fn = assert(loadfile("/cpm/packages/%s/%s", nil, _ENV))', name, startup), "return fn()", "", }, "\n") ) return hook end --- Regenerate the shims for the package currently installed under /cpm/packages/, --- warning when a shim written by another package gets taken over. function store.writeShims(name) for _, program in ipairs(binPrograms(name)) do local owner = shimOwner(program) if owner and owner ~= name then print(string.format("Warning: %s replaces the `%s` command from %s", name, program, owner)) end store.writeFile(shimPath(program), shimSource(name, program)) end end --- Remove the shims owned by the package currently installed under /cpm/packages/, --- leaving any that another package has since taken over. function store.removeShims(name) for _, program in ipairs(binPrograms(name)) do if shimOwner(program) == name then fs.delete(shimPath(program)) end end end --- Swap a fully extracted staging tree into place: the old tree is deleted only once the new --- one is complete, so a failed download or extraction never leaves a half-written package live. function store.commit(name) local staging = store.stagingDir(name) local target = store.packageDir(name) -- An empty bundle never creates the staging dir; fail before touching the installed tree -- so a bad bundle cannot destroy a working install. if not fs.exists(staging) then error("nothing staged for " .. name, 0) end store.removeShims(name) if fs.exists(target) then fs.delete(target) end if not fs.exists(store.PACKAGES) then fs.makeDir(store.PACKAGES) end fs.move(staging, target) store.writeShims(name) store.syncStartupHook(name) end function store.clearStaging(name) local staging = store.stagingDir(name) if fs.exists(staging) then fs.delete(staging) end end function store.removePackage(name) store.removeShims(name) local hook = startupHookPath(name) if fs.exists(hook) then fs.delete(hook) end local target = store.packageDir(name) if fs.exists(target) then fs.delete(target) end end --- Startup drop-in: puts /cpm/bin on the shell path and runs the require hook (hook.lua in --- the cpm package, the hook's single source), without touching the user's startup.lua. function store.writeStartup() store.writeFile( store.STARTUP, table.concat({ 'if not shell.path():find(":/cpm/bin", 1, true) then', ' shell.setPath(shell.path() .. ":/cpm/bin")', "end", 'if fs.exists("' .. store.HOOK .. '") then', ' dofile("' .. store.HOOK .. '")', "end", "", }, "\n") ) end --- Add /cpm/bin to the running shell so programs work without a reboot. function store.ensureShellPath() if not shell.path():find(":/cpm/bin", 1, true) then shell.setPath(shell.path() .. ":/cpm/bin") end end --- Activate the require hook for the running session (the startup drop-in runs the same file --- at boot). dofile runs it against _G, which is all the hook touches; see src/hook.lua. function store.ensureRequireHook() if not _G.__cpm_require_hook and fs.exists(store.HOOK) then dofile(store.HOOK) end end return store -- Brings /cpm/packages in line with a set of roots: resolve on the registry, diff against the -- installed set, download and verify bundles, swap them in, and garbage-collect orphans. -- install, update, and remove are all thin wrappers around this one flow. local registry = require("cpm.registry") local bundle = require("cpm.bundle") local store = require("cpm.store") local state = require("cpm.state") local MAX_CONCURRENT_DOWNLOADS = 4 local sync = {} local function formatSize(bytes) if bytes >= 1024 then return string.format("%.1f KB", bytes / 1024) end return string.format("%d B", bytes) end function sync.resolve(roots) local data, err = registry.post("/resolve", { dependencies = roots }) if not data then error("resolve failed: " .. err, 0) end if type(data.packages) ~= "table" then error("resolve failed: registry returned no package list", 0) end return data.packages end -- Disk is the scarce resource on CC (1 MB by default), so refuse up front rather than fail -- halfway through extraction with a full drive. local function checkFreeSpace(toInstall) local needed = 0 for _, pkg in ipairs(toInstall) do needed = needed + (pkg.dist.bundle.size or 0) end local free = fs.getFreeSpace("/") if needed > free then local message = "not enough disk space: %s needed, %s free" error(string.format(message, formatSize(needed), formatSize(free)), 0) end end -- Bundles are small, so they are held in memory and fetched a few at a time. Each task records -- its own outcome so one failed download can be reported by name instead of as a raw error. local function downloadAll(toInstall) local bytesByName = {} local failures = {} local function downloader(pkg) return function() local bytes, err = registry.getBundle(pkg.dist.bundle.url) if bytes then bytesByName[pkg.name] = bytes else failures[#failures + 1] = string.format("%s@%s: %s", pkg.name, pkg.version, err) end end end for first = 1, #toInstall, MAX_CONCURRENT_DOWNLOADS do local tasks = {} for i = first, math.min(first + MAX_CONCURRENT_DOWNLOADS - 1, #toInstall) do tasks[#tasks + 1] = downloader(toInstall[i]) end parallel.waitForAll(table.unpack(tasks)) end if #failures > 0 then error("download failed for " .. table.concat(failures, "; "), 0) end return bytesByName end local function installPackage(pkg, bytes) local ok, err = bundle.verify(bytes, pkg.dist.bundle.sha256) if not ok then error(string.format("%s@%s: %s", pkg.name, pkg.version, err), 0) end store.clearStaging(pkg.name) ok, err = bundle.extract(bytes, store.stagingDir(pkg.name)) if not ok then store.clearStaging(pkg.name) error(string.format("%s@%s: %s", pkg.name, pkg.version, err), 0) end store.commit(pkg.name) end --- Resolve `roots` and make the installed set match. Returns the resolved package list. function sync.apply(roots) local current = state.load() current.roots = roots local resolved = {} if next(roots) ~= nil then print("Resolving...") resolved = sync.resolve(roots) end local resolvedByName = {} local toInstall = {} for _, pkg in ipairs(resolved) do resolvedByName[pkg.name] = pkg if current.installed[pkg.name] ~= pkg.version then toInstall[#toInstall + 1] = pkg end end local orphans = {} for name in pairs(current.installed) do if not resolvedByName[name] then orphans[#orphans + 1] = name end end table.sort(orphans) if #toInstall > 0 then checkFreeSpace(toInstall) for _, pkg in ipairs(toInstall) do print( string.format( "Downloading %s@%s (%s)", pkg.name, pkg.version, formatSize(pkg.dist.bundle.size or 0) ) ) end local bytesByName = downloadAll(toInstall) -- Resolve order puts dependencies first; state is saved after every swap so an abort -- midway never records a package that is not actually on disk. for _, pkg in ipairs(toInstall) do installPackage(pkg, bytesByName[pkg.name]) bytesByName[pkg.name] = nil current.installed[pkg.name] = pkg.version state.save(current) print(string.format("Installed %s@%s", pkg.name, pkg.version)) end end -- Cleared from state before the files go: an interruption can leak an untracked tree, but -- state.json never claims a package whose files are gone. for _, name in ipairs(orphans) do current.installed[name] = nil state.save(current) store.removePackage(name) print("Removed " .. name) end state.save(current) -- Rewrite the startup drop-in on every sync so a cpm upgrade rolls out new content to -- existing computers, and activate the shell path and require hook for this session. store.writeStartup() store.ensureShellPath() store.ensureRequireHook() if #toInstall == 0 and #orphans == 0 then print("Already up to date") end return resolved end return sync