diff --git a/CLAUDE.md b/CLAUDE.md index 3efcb27..c5b4cbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,9 @@ Use `docs/README.md` as the entrypoint for CC:Tweaked, CraftOS-PC, Advanced Peri - `startup/servers.lua` starts `/programs`, the shell, and configured servers via `parallel.waitForAll`. - Preserve `periphemu` guards used for CraftOS-PC emulation; see `docs/craftos_pc_glossary.md` for upstream emulator references. -- `install.lua` downloads files listed in `LIST_FILES` from `master` by default, or from `next` with `--beta`; add shipped files there. +- TrapOS ships as packages, each described by `packages//ccpm.json` (`name`, `version`, `dependencies`, `files`, `autostart`); `packages/index.json` lists them. Source files stay in place — descriptors only reference them. To ship a new file, add it to the right package's `files` (and `autostart` if it is a server). See ADR-0010. +- `install.lua` reads `manifest.json` (`packages` list) from `master` by default, or `next` with `--beta`; it resolves descriptors, downloads files, and writes `/trapos/manifest.json` (aggregated OS state for motd/servers/upgrade) plus the ccpm state files. `--core` installs only `tos-core`. +- `ccpm` (in `tos-core`) is the package manager: `apis/libccpm.lua` is the testable core (factory with injectable `http`/`stateDir`/`installRoot`), `programs/ccpm.lua` the CLI. State: `/trapos/ccpm.json` (registries) and `/trapos/ccpm.lock.json` (installed packages). Never use the word "manifest" in ccpm — it is reserved for the OS manifest. - Add new servers to `startup/servers.lua` as needed. ## Conventions diff --git a/Justfile b/Justfile index 95d1a71..2ba041f 100644 --- a/Justfile +++ b/Justfile @@ -88,9 +88,11 @@ craftos *args: check-install argv+=(--rom /Applications/CraftOS-PC.app/Contents/Resources) fi argv+=(--mount-ro "/trapos=$repo") - while IFS= read -r dir; do - argv+=(--mount-ro "/$dir=$repo/$dir") - done < <(jq -r '.files[] | split("/")[0]' "$repo/manifest.json" | sort -u) + for dir in apis programs servers startup tests; do + if [ -d "$repo/$dir" ]; then + argv+=(--mount-ro "/$dir=$repo/$dir") + fi + done exec craftos "${argv[@]}" "$@" # Human-only interactive REPL. LLM agents must not execute this command. diff --git a/README.md b/README.md index 4bc3cbd..54fc739 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,20 @@ A small in-game operating system for ComputerCraft / CC:Tweaked, built around a ## Installation +Full install (all packages): ``` wget run https://raw.githubusercontent.com/guillaumearm/cc-libs/master/install.lua ``` +Minimal install (only `tos-core`, i.e. just the package manager) so you can cherry-pick the rest yourself: +``` +wget run https://raw.githubusercontent.com/guillaumearm/cc-libs/master/install.lua --core +``` +``` +> ccpm install tos-net +> ccpm install tos-ui +``` + Install the beta branch (one-time opt-in, asks for confirmation): ``` wget run https://raw.githubusercontent.com/guillaumearm/cc-libs/next/install.lua --beta @@ -17,9 +27,36 @@ Once a machine is on beta, `upgrade` keeps it on beta — `--beta` is not needed After install, every boot shows a colored MOTD with the installed version and branch (lime for stable, orange + `[BETA]` for beta). -## Manifest +## Packages -The installed file list, version, autostart servers, and current branch all live in `manifest.json` at the repo root. A copy is persisted locally at `/trapos/manifest.json` after install, and drives `upgrade`, `startup/motd.lua`, and `startup/servers.lua`. +TrapOS is split into packages, each described by a `packages//ccpm.json`: + +- `tos-core`: the package manager (`ccpm`), event loop, `upgrade`, `events`. +- `tos-test`: the test framework (`libtest`) and suite runner (`runtest`). +- `tos-boot`: the startup MOTD and autostart server launcher. +- `tos-net`: routed modem networking (`net`, `router`, `ping`, `ping-server`). +- `tos-ui`: the terminal UI toolkit (`libtui`, `tuidemo`). + +`manifest.json` at the repo root lists which packages a full install ships. The +bootstrap resolves their descriptors, downloads the files, and writes the aggregated +`/trapos/manifest.json` (version, branch, files, autostart) that drives `upgrade`, +`startup/motd.lua`, and `startup/servers.lua`. + +## ccpm + +`ccpm` is the TrapOS package manager (shipped in `tos-core`). It is configured at +install with a default registry (`guillaumearm/cc-libs`). + +``` +ccpm install ccpm reinstall ccpm uninstall +ccpm ls ccpm search [term] ccpm info +ccpm registry ls ccpm registry add [--branch ] [--type github|http] +ccpm registry rm +``` + +Registries default to GitHub (`owner/repo`); `http`/`https` base URLs are also +supported. State lives in `/trapos/ccpm.json` (registries) and `/trapos/ccpm.lock.json` +(installed packages). See [ADR-0010](docs/adrs/adr-0010-ccpm-package-manager.md). ## APIs - `/apis/eventloop`: a simple event loop API. diff --git a/apis/libccpm.lua b/apis/libccpm.lua new file mode 100644 index 0000000..367e6d2 --- /dev/null +++ b/apis/libccpm.lua @@ -0,0 +1,343 @@ +local _VERSION = '0.1.0'; + +-- libccpm: the testable core of the TrapOS package manager (ccpm). +-- +-- A factory: `local createCcpm = require('/apis/libccpm'); local ccpm = createCcpm();` +-- +-- State lives under `opts.stateDir` (default `/trapos`): +-- - ccpm.json -> { registries = { { name, type, branch }, ... } } +-- - ccpm.lock.json -> { packages = { = { version, registry, files, +-- dependencies, autostart } } } +-- +-- Files are written under `opts.installRoot` (default '' -> filesystem root), so +-- tests can sandbox downloads. `opts.http` overrides the global `http` for tests. + +local DEFAULT_STATE_DIR = '/trapos'; + +local function normalizePath(path) + path = path:gsub('//+', '/'); + if path:sub(1, 1) ~= '/' then + path = '/' .. path; + end + return path; +end + +local function parentDir(path) + return path:match('^(.*)/[^/]+$'); +end + +local function createCcpm(opts) + opts = opts or {}; + local httpLib = opts.http or http; + local stateDir = opts.stateDir or DEFAULT_STATE_DIR; + local installRoot = opts.installRoot or ''; + + local configPath = stateDir .. '/ccpm.json'; + local lockPath = stateDir .. '/ccpm.lock.json'; + + local api = {}; + + -- ---------- JSON file helpers ---------- + + local function readJsonFile(path) + if not fs.exists(path) then return nil; end + local f = fs.open(path, 'r'); + if not f then return nil; end + local data = f.readAll(); + f.close(); + if not data or data == '' then return nil; end + return textutils.unserializeJSON(data); + end + + local function writeJsonFile(path, value) + local dir = parentDir(path); + if dir then fs.makeDir(dir); end + local f = fs.open(path, 'w'); + if not f then return false; end + f.write(textutils.serializeJSON(value)); + f.close(); + return true; + end + + -- ---------- config (registries) ---------- + + function api.readConfig() + return readJsonFile(configPath) or { registries = {} }; + end + + function api.writeConfig(cfg) + return writeJsonFile(configPath, cfg); + end + + function api.listRegistries() + return api.readConfig().registries or {}; + end + + function api.addRegistry(name, registryOpts) + registryOpts = registryOpts or {}; + local cfg = api.readConfig(); + cfg.registries = cfg.registries or {}; + for _, r in ipairs(cfg.registries) do + if r.name == name then + return false, 'registry already exists: ' .. name; + end + end + local registry = { + name = name, + type = registryOpts.type or 'github', + branch = registryOpts.branch or 'master', + }; + cfg.registries[#cfg.registries + 1] = registry; + api.writeConfig(cfg); + return true, registry; + end + + function api.removeRegistry(name) + local cfg = api.readConfig(); + cfg.registries = cfg.registries or {}; + local kept = {}; + local found = false; + for _, r in ipairs(cfg.registries) do + if r.name == name then + found = true; + else + kept[#kept + 1] = r; + end + end + if not found then + return false, 'registry not found: ' .. name; + end + cfg.registries = kept; + api.writeConfig(cfg); + return true; + end + + -- ---------- lock (installed packages) ---------- + + function api.readLock() + return readJsonFile(lockPath) or { packages = {} }; + end + + function api.writeLock(lock) + return writeJsonFile(lockPath, lock); + end + + function api.list() + return api.readLock().packages or {}; + end + + -- ---------- URL resolution ---------- + + function api.registryBaseUrl(registry) + if registry.type == 'github' then + local branch = registry.branch or 'master'; + return 'https://raw.githubusercontent.com/' .. registry.name .. '/' .. branch .. '/'; + end + local base = registry.name; + if base:sub(-1) ~= '/' then base = base .. '/'; end + return base; + end + + function api.descriptorUrl(registry, pkg) + return api.registryBaseUrl(registry) .. 'packages/' .. pkg .. '/ccpm.json'; + end + + function api.indexUrl(registry) + return api.registryBaseUrl(registry) .. 'packages/index.json'; + end + + function api.fileUrl(registry, filePath) + return api.registryBaseUrl(registry) .. filePath; + end + + -- ---------- HTTP ---------- + + local function httpGetBody(url) + local res = httpLib.get(url); + if not res then return nil; end + local body = res.readAll(); + res.close(); + return body; + end + + function api.fetchJson(url) + local body = httpGetBody(url); + if not body or body == '' then return nil; end + return textutils.unserializeJSON(body); + end + + -- Find a package descriptor across the configured registries. + -- Returns descriptor, registry (or nil when not found). + function api.findPackage(pkg) + local cfg = api.readConfig(); + for _, registry in ipairs(cfg.registries or {}) do + local desc = api.fetchJson(api.descriptorUrl(registry, pkg)); + if desc then return desc, registry; end + end + return nil; + end + + function api.info(pkg) + return api.findPackage(pkg); + end + + function api.search(term) + local cfg = api.readConfig(); + local results = {}; + for _, registry in ipairs(cfg.registries or {}) do + local index = api.fetchJson(api.indexUrl(registry)); + if index and index.packages then + for name, version in pairs(index.packages) do + if not term or term == '' or string.find(name, term, 1, true) then + results[#results + 1] = { name = name, version = version, registry = registry.name }; + end + end + end + end + table.sort(results, function(a, b) return a.name < b.name; end); + return results; + end + + -- ---------- dependency resolution ---------- + + -- Resolve `pkg` and all of its dependencies into install order (deps first, + -- target last). Returns an ordered list of { name, desc, registry } or nil, err. + function api.resolve(pkg) + local ordered = {}; + local state = {}; -- name -> 'visiting' | 'done' + local errMsg = nil; + + local function visit(name, chain) + if errMsg then return; end + if state[name] == 'done' then return; end + if state[name] == 'visiting' then + errMsg = 'dependency cycle detected: ' .. table.concat(chain, ' -> ') .. ' -> ' .. name; + return; + end + state[name] = 'visiting'; + local desc, registry = api.findPackage(name); + if not desc then + errMsg = 'package not found: ' .. name; + return; + end + chain[#chain + 1] = name; + for _, dep in ipairs(desc.dependencies or {}) do + visit(dep, chain); + if errMsg then return; end + end + chain[#chain] = nil; + state[name] = 'done'; + ordered[#ordered + 1] = { name = name, desc = desc, registry = registry }; + end + + visit(pkg, {}); + if errMsg then return nil, errMsg; end + return ordered; + end + + -- ---------- install / uninstall ---------- + + local function installTarget(filePath) + return normalizePath(installRoot .. '/' .. filePath); + end + + function api.downloadFile(registry, filePath) + local body = httpGetBody(api.fileUrl(registry, filePath)); + if not body then + return false, 'failed to download ' .. filePath; + end + local target = installTarget(filePath); + local dir = parentDir(target); + if dir then fs.makeDir(dir); end + fs.delete(target); + local f = fs.open(target, 'w'); + if not f then + return false, 'failed to write ' .. target; + end + f.write(body); + f.close(); + return true; + end + + -- installOpts: { force = bool, log = function(msg) } + function api.install(pkg, installOpts) + installOpts = installOpts or {}; + local log = installOpts.log or function() end; + local lock = api.readLock(); + lock.packages = lock.packages or {}; + + if lock.packages[pkg] and not installOpts.force then + return false, "package already installed, use 'ccpm reinstall " .. pkg .. "' instead."; + end + + local ordered, err = api.resolve(pkg); + if not ordered then return false, err; end + + for _, item in ipairs(ordered) do + local isTarget = item.name == pkg; + local alreadyInstalled = lock.packages[item.name] ~= nil; + if alreadyInstalled and not (isTarget and installOpts.force) then + log('skip ' .. item.name .. ' (already installed)'); + else + log('install ' .. item.name .. ' v' .. tostring(item.desc.version or '?')); + for _, filePath in ipairs(item.desc.files or {}) do + log(' ' .. filePath); + local ok, derr = api.downloadFile(item.registry, filePath); + if not ok then return false, derr; end + end + lock.packages[item.name] = { + version = item.desc.version, + registry = item.registry.name, + files = item.desc.files or {}, + dependencies = item.desc.dependencies or {}, + autostart = item.desc.autostart or {}, + }; + end + end + + api.writeLock(lock); + return true, lock.packages[pkg]; + end + + -- uninstallOpts: { force = bool, log = function(msg) } + function api.uninstall(pkg, uninstallOpts) + uninstallOpts = uninstallOpts or {}; + local log = uninstallOpts.log or function() end; + local lock = api.readLock(); + lock.packages = lock.packages or {}; + + local entry = lock.packages[pkg]; + if not entry then + return false, 'package not installed: ' .. pkg; + end + + local dependents = {}; + for name, e in pairs(lock.packages) do + if name ~= pkg then + for _, dep in ipairs(e.dependencies or {}) do + if dep == pkg then dependents[#dependents + 1] = name; end + end + end + end + if #dependents > 0 and not uninstallOpts.force then + table.sort(dependents); + return false, 'cannot uninstall ' .. pkg .. ': required by ' .. table.concat(dependents, ', '); + end + + for _, filePath in ipairs(entry.files or {}) do + log('remove ' .. filePath); + fs.delete(installTarget(filePath)); + end + lock.packages[pkg] = nil; + api.writeLock(lock); + return true; + end + + function api.version() + return _VERSION; + end + + return api; +end + +return createCcpm; diff --git a/docs/adrs/README.md b/docs/adrs/README.md index 544d395..7a52098 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -17,3 +17,4 @@ Future ADRs can reuse the shape of the existing files when it is useful. - [`adr-0007-use-libtest-for-craftos-tests.md`](adr-0007-use-libtest-for-craftos-tests.md) - Use libtest for CraftOS tests. - [`adr-0008-keep-tests-runnable-in-craftos-and-in-game.md`](adr-0008-keep-tests-runnable-in-craftos-and-in-game.md) - Keep tests runnable in CraftOS and in-game. - [`adr-0009-layered-test-timeouts.md`](adr-0009-layered-test-timeouts.md) - Layered test timeouts (libtest per-case + shell watchdog). +- [`adr-0010-ccpm-package-manager.md`](adr-0010-ccpm-package-manager.md) - ccpm package manager (packages, registries, package-aware bootstrap). diff --git a/docs/adrs/adr-0010-ccpm-package-manager.md b/docs/adrs/adr-0010-ccpm-package-manager.md new file mode 100644 index 0000000..b9a1897 --- /dev/null +++ b/docs/adrs/adr-0010-ccpm-package-manager.md @@ -0,0 +1,95 @@ +# ADR 0010: ccpm Package Manager + +## Status + +Accepted + +## Date + +2026-06-08 + +## Context + +ADR 0004 made installs manifest-driven: `install.lua` reads a flat `manifest.json` +file list from a branch and `wget`s every file. That is all-or-nothing. There is no +way to install just networking or just the UI, and no way to add or remove pieces of +the OS after the initial install. + +We want a package manager, `ccpm` ("ComputerCraft Package Manager"), shipped as part +of the base OS, so a machine can `ccpm install tos-net`, `ccpm uninstall tos-ui`, and +manage where packages come from. TrapOS itself is **not** becoming a package for now; +the `wget run .../install.lua` bootstrap stays the recommended entry point. + +## Decision + +### Packages are descriptors over the existing tree + +Source files stay where they are (`apis/`, `programs/`, `servers/`, `startup/`); their +install targets remain the same absolute CC paths, so `require` paths and the dev +mounts are unchanged. A package is a descriptor that *references* those files: +`packages//ccpm.json` with `{ name, version, description, dependencies, files, +autostart }`. `packages/index.json` lists the packages a registry offers (for +`ccpm search`). There is no `ccpm.json` at the repo root. + +The split is finer-grained than the install examples imply: + +| package | contents | deps | +|----------|-----------------------------------------------------------------|----------| +| tos-core | ccpm, libccpm, eventloop, upgrade, events | — | +| tos-test | libtest, runtest | tos-core | +| tos-boot | motd, servers (startup) | tos-core | +| tos-net | net, router, ping, ping-server | tos-core | +| tos-ui | libtui, tuidemo | tos-core | + +### Two files for ccpm, "manifest" reserved for the OS + +To avoid colliding with the OS `manifest.json`, ccpm never uses the word "manifest". +Local state lives under `/trapos`: + +- `ccpm.json` — ordered registry list `{ registries = { { name, type, branch } } }`. + `type` is `github` (resolves to `raw.githubusercontent.com///`) or + `http`/`https` (the `name` is a base URL). +- `ccpm.lock.json` — installed packages `{ packages = { = { version, registry, + files, dependencies, autostart } } }`, used by `ls`, `uninstall`, and `reinstall`. + +`apis/libccpm.lua` is the testable core (a factory; `http`/`stateDir`/`installRoot` +are injectable for tests). `programs/ccpm.lua` is a thin CLI over it. + +### The bootstrap is package-aware + +`manifest.json` now lists `packages` instead of `files`. `install.lua` resolves each +package descriptor (pulling dependencies), downloads the union of their files, and +writes: + +- `/trapos/manifest.json` — the aggregated `{ name, version, branch, files, autostart }` + still consumed verbatim by `startup/motd.lua`, `startup/servers.lua`, and + `programs/upgrade.lua` (those three are unchanged); +- `/trapos/ccpm.lock.json` — so right after a fresh install `ccpm install tos-core` + correctly reports "already installed"; +- `/trapos/ccpm.json` — seeding/refreshing the default `guillaumearm/cc-libs` registry + to track the install branch. + +Two install paths follow: + +- `wget run .../install.lua` — full OS (all packages in `manifest.packages`). +- `wget run .../install.lua --core` — only `tos-core` (i.e. just `ccpm`); the user then + cherry-picks with `ccpm install ...`. + +On a subsequent `upgrade`, `install.lua` prefers the existing lockfile's package set +over `manifest.packages`, so a cherry-picked machine upgrades only what it actually has. + +## Consequences + +- The repo gains a `packages/` descriptor tree; the flat source layout is untouched. +- `just craftos` no longer derives mounts from `manifest.json .files` (it is now + `.packages`); it mounts a fixed list of top-level dirs instead. `just test` was + already on fixed mounts and is unaffected. +- ccpm logic is covered by `tests/ccpm.lua` (URL resolution, dependency ordering, + cycle/missing detection, already-installed, registry CRUD, uninstall dependency + guard) with an injected `http` stub — no network in tests. + +## Future Work + +- Version ranges / `ccpm update` (today a single pinned version per package). +- Making TrapOS self-update through ccpm rather than the `wget run` bootstrap. +- http/https registries beyond a plain base URL (auth, caching). diff --git a/install.lua b/install.lua index 823877b..dfafb11 100644 --- a/install.lua +++ b/install.lua @@ -1,20 +1,24 @@ -local _VERSION = '3.0.0'; +local _VERSION = '4.0.0'; local REPO_BASE = 'https://raw.githubusercontent.com/guillaumearm/cc-libs/'; local LOCAL_STATE_DIR = '/trapos'; local LOCAL_MANIFEST_PATH = '/trapos/manifest.json'; +local LOCAL_CONFIG_PATH = '/trapos/ccpm.json'; +local LOCAL_LOCK_PATH = '/trapos/ccpm.lock.json'; +local DEFAULT_REGISTRY_NAME = 'guillaumearm/cc-libs'; local function printUsage() print('install usage:'); print(); print('\t\twget run '); + print('\t\twget run --core'); print('\t\twget run --beta'); print('\t\twget run --stable'); end -local function readLocalManifest() - if not fs.exists(LOCAL_MANIFEST_PATH) then return nil end - local f = fs.open(LOCAL_MANIFEST_PATH, 'r'); +local function readJsonFile(path) + if not fs.exists(path) then return nil end + local f = fs.open(path, 'r'); if not f then return nil end local data = f.readAll(); f.close(); @@ -22,11 +26,11 @@ local function readLocalManifest() return textutils.unserializeJSON(data); end -local function writeLocalManifest(manifest) +local function writeJsonFile(path, value) fs.makeDir(LOCAL_STATE_DIR); - local f = fs.open(LOCAL_MANIFEST_PATH, 'w'); + local f = fs.open(path, 'w'); if not f then return false end - f.write(textutils.serializeJSON(manifest)); + f.write(textutils.serializeJSON(value)); f.close(); return true; end @@ -42,8 +46,7 @@ local function confirmBeta() return answer == 'y' or answer == 'yes'; end -local function fetchManifest(branch) - local url = REPO_BASE .. branch .. '/manifest.json'; +local function fetchJson(url) local response = http.get(url); if not response then return nil end local body = response.readAll(); @@ -52,30 +55,88 @@ local function fetchManifest(branch) return textutils.unserializeJSON(body); end -local command = ...; -local forceBeta = false; -local forceStable = false; - -if command == 'version' or command == '-version' or command == '--version' then - print('install v' .. _VERSION); - return; +local function fetchManifest(branch) + return fetchJson(REPO_BASE .. branch .. '/manifest.json'); end -if command == 'help' or command == '-help' or command == '--help' then - printUsage(); - return; +local function fetchDescriptor(branch, pkg) + return fetchJson(REPO_BASE .. branch .. '/packages/' .. pkg .. '/ccpm.json'); end -if command == '--beta' or command == '-beta' then - forceBeta = true; -elseif command == '--stable' or command == '-stable' then - forceStable = true; -elseif command ~= nil and command ~= '' then - printUsage(); - return; +-- Resolve a list of package names + their dependencies into install order +-- (deps first). Returns an ordered list of descriptors or nil, err. +local function resolvePackages(branch, names) + local ordered = {}; + local state = {}; -- name -> 'visiting' | 'done' + + local function visit(name) + if state[name] == 'done' then return true end + if state[name] == 'visiting' then + return false, 'dependency cycle detected at ' .. name; + end + state[name] = 'visiting'; + local desc = fetchDescriptor(branch, name); + if not desc then + return false, 'package not found: ' .. name; + end + for _, dep in ipairs(desc.dependencies or {}) do + local ok, err = visit(dep); + if not ok then return false, err end + end + state[name] = 'done'; + ordered[#ordered + 1] = desc; + return true; + end + + for _, name in ipairs(names) do + local ok, err = visit(name); + if not ok then return nil, err end + end + return ordered; end -local localManifest = readLocalManifest(); +-- Seed/refresh the default ccpm registry so it tracks the install branch. +local function seedCcpmConfig(branch) + local cfg = readJsonFile(LOCAL_CONFIG_PATH) or { registries = {} }; + cfg.registries = cfg.registries or {}; + local found = false; + for _, r in ipairs(cfg.registries) do + if r.name == DEFAULT_REGISTRY_NAME then + r.type = 'github'; + r.branch = branch; + found = true; + end + end + if not found then + table.insert(cfg.registries, 1, { name = DEFAULT_REGISTRY_NAME, type = 'github', branch = branch }); + end + writeJsonFile(LOCAL_CONFIG_PATH, cfg); +end + +local rawArgs = table.pack(...); +local forceBeta, forceStable, forceCore = false, false, false; + +for i = 1, rawArgs.n do + local a = rawArgs[i]; + if a == 'version' or a == '-version' or a == '--version' then + print('install v' .. _VERSION); + return; + elseif a == 'help' or a == '-help' or a == '--help' then + printUsage(); + return; + elseif a == '--beta' or a == '-beta' then + forceBeta = true; + elseif a == '--stable' or a == '-stable' then + forceStable = true; + elseif a == '--core' or a == '-core' then + forceCore = true; + elseif a ~= nil and a ~= '' then + printUsage(); + return; + end +end + +local localManifest = readJsonFile(LOCAL_MANIFEST_PATH); local localBranch = localManifest and localManifest.branch or nil; local branch; @@ -95,13 +156,39 @@ end print('Fetching manifest from branch: ' .. branch); local manifest = fetchManifest(branch); -if not manifest or type(manifest.files) ~= 'table' then +if not manifest then print('Failed to fetch or parse manifest.json from ' .. branch); return; end --- The persisted branch reflects the actually-used branch, not the manifest default. -manifest.branch = branch; +-- Decide which packages to install: +-- --core -> only tos-core (the package manager bootstrap) +-- existing lockfile -> whatever is already installed (upgrade in place) +-- otherwise -> the full set listed in manifest.packages +local requested; +if forceCore then + requested = { 'tos-core' }; +else + local lock = readJsonFile(LOCAL_LOCK_PATH); + if lock and type(lock.packages) == 'table' and next(lock.packages) then + requested = {}; + for name in pairs(lock.packages) do requested[#requested + 1] = name end + table.sort(requested); + else + requested = manifest.packages; + end +end + +if type(requested) ~= 'table' or #requested == 0 then + print('No packages to install (manifest.packages missing?)'); + return; +end + +local resolved, resolveErr = resolvePackages(branch, requested); +if not resolved then + print('Failed to resolve packages: ' .. resolveErr); + return; +end local REPO_PREFIX = REPO_BASE .. branch .. '/'; @@ -125,17 +212,51 @@ fs.makeDir('/startup'); fs.makeDir('/servers'); fs.makeDir(LOCAL_STATE_DIR); -for _, filePath in ipairs(manifest.files) do - fs.delete(filePath); - shell.execute('wget', REPO_PREFIX .. filePath, filePath); +local allFiles = {}; +local seenFile = {}; +local autostart = {}; +local seenAuto = {}; +local lockPackages = {}; + +for _, desc in ipairs(resolved) do + for _, filePath in ipairs(desc.files or {}) do + if not seenFile[filePath] then + seenFile[filePath] = true; + allFiles[#allFiles + 1] = filePath; + fs.delete(filePath); + shell.execute('wget', REPO_PREFIX .. filePath, filePath); + end + end + for _, srv in ipairs(desc.autostart or {}) do + if not seenAuto[srv] then + seenAuto[srv] = true; + autostart[#autostart + 1] = srv; + end + end + lockPackages[desc.name] = { + version = desc.version, + registry = DEFAULT_REGISTRY_NAME, + files = desc.files or {}, + dependencies = desc.dependencies or {}, + autostart = desc.autostart or {}, + }; end -if not writeLocalManifest(manifest) then - print('Warning: failed to write local manifest'); -end +-- Aggregated OS state for motd/servers/upgrade (unchanged consumers). +writeJsonFile(LOCAL_MANIFEST_PATH, { + name = manifest.name or 'TrapOS', + version = manifest.version or '?', + branch = branch, + files = allFiles, + autostart = autostart, +}); + +writeJsonFile(LOCAL_LOCK_PATH, { packages = lockPackages }); +seedCcpmConfig(branch); print(); print('=> TrapOS v' .. (manifest.version or '?') .. ' installed (branch: ' .. branch .. ')'); +print('=> Packages: ' .. table.concat(requested, ', ')); print('=> Execute startup/servers.lua'); shell.execute('/startup/servers.lua'); diff --git a/manifest.json b/manifest.json index 08ccb88..9f44a1c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,23 +1,12 @@ { "name": "TrapOS", - "version": "0.3.0", + "version": "0.4.0", "branch": "next", - "files": [ - "startup/motd.lua", - "startup/servers.lua", - "servers/ping-server.lua", - "programs/router.lua", - "programs/events.lua", - "programs/ping.lua", - "programs/runtest.lua", - "programs/tuidemo.lua", - "programs/upgrade.lua", - "apis/net.lua", - "apis/eventloop.lua", - "apis/libtest.lua", - "apis/libtui.lua" - ], - "autostart": [ - "servers/ping-server" + "packages": [ + "tos-core", + "tos-test", + "tos-boot", + "tos-net", + "tos-ui" ] } diff --git a/packages/index.json b/packages/index.json new file mode 100644 index 0000000..ce6151c --- /dev/null +++ b/packages/index.json @@ -0,0 +1,9 @@ +{ + "packages": { + "tos-core": "0.1.0", + "tos-test": "0.1.0", + "tos-boot": "0.1.0", + "tos-net": "0.1.0", + "tos-ui": "0.1.0" + } +} diff --git a/packages/tos-boot/ccpm.json b/packages/tos-boot/ccpm.json new file mode 100644 index 0000000..8d113e4 --- /dev/null +++ b/packages/tos-boot/ccpm.json @@ -0,0 +1,11 @@ +{ + "name": "tos-boot", + "version": "0.1.0", + "description": "TrapOS boot: startup MOTD and autostart server launcher", + "dependencies": ["tos-core"], + "files": [ + "startup/motd.lua", + "startup/servers.lua" + ], + "autostart": [] +} diff --git a/packages/tos-core/ccpm.json b/packages/tos-core/ccpm.json new file mode 100644 index 0000000..e49ab3e --- /dev/null +++ b/packages/tos-core/ccpm.json @@ -0,0 +1,14 @@ +{ + "name": "tos-core", + "version": "0.1.0", + "description": "TrapOS base: package manager, event loop, upgrade and event tools", + "dependencies": [], + "files": [ + "apis/eventloop.lua", + "apis/libccpm.lua", + "programs/ccpm.lua", + "programs/upgrade.lua", + "programs/events.lua" + ], + "autostart": [] +} diff --git a/packages/tos-net/ccpm.json b/packages/tos-net/ccpm.json new file mode 100644 index 0000000..eff2311 --- /dev/null +++ b/packages/tos-net/ccpm.json @@ -0,0 +1,13 @@ +{ + "name": "tos-net", + "version": "0.1.0", + "description": "TrapOS networking: routed modem messaging, router, ping", + "dependencies": ["tos-core"], + "files": [ + "apis/net.lua", + "programs/router.lua", + "programs/ping.lua", + "servers/ping-server.lua" + ], + "autostart": ["servers/ping-server"] +} diff --git a/packages/tos-test/ccpm.json b/packages/tos-test/ccpm.json new file mode 100644 index 0000000..e92df28 --- /dev/null +++ b/packages/tos-test/ccpm.json @@ -0,0 +1,11 @@ +{ + "name": "tos-test", + "version": "0.1.0", + "description": "TrapOS test framework and CraftOS-PC suite runner", + "dependencies": ["tos-core"], + "files": [ + "apis/libtest.lua", + "programs/runtest.lua" + ], + "autostart": [] +} diff --git a/packages/tos-ui/ccpm.json b/packages/tos-ui/ccpm.json new file mode 100644 index 0000000..bd1dd5d --- /dev/null +++ b/packages/tos-ui/ccpm.json @@ -0,0 +1,11 @@ +{ + "name": "tos-ui", + "version": "0.1.0", + "description": "TrapOS terminal UI toolkit and demo", + "dependencies": ["tos-core"], + "files": [ + "apis/libtui.lua", + "programs/tuidemo.lua" + ], + "autostart": [] +} diff --git a/programs/ccpm.lua b/programs/ccpm.lua new file mode 100644 index 0000000..bef9f9c --- /dev/null +++ b/programs/ccpm.lua @@ -0,0 +1,169 @@ +local _VERSION = '0.1.0'; + +local createCcpm = require('/apis/libccpm'); + +local args = table.pack(...); +local command = args[1]; + +local function printUsage() + print('ccpm usage:'); + print(); + print('\t\tccpm install '); + print('\t\tccpm reinstall '); + print('\t\tccpm uninstall '); + print('\t\tccpm ls'); + print('\t\tccpm search [term]'); + print('\t\tccpm info '); + print('\t\tccpm registry ls'); + print('\t\tccpm registry add [--branch ] [--type github|http]'); + print('\t\tccpm registry rm '); + print('\t\tccpm version'); + print('\t\tccpm help'); +end + +if command == 'version' or command == '-version' or command == '--version' then + print('ccpm v' .. _VERSION); + return; +end + +if command == nil or command == '' or command == 'help' or command == '-help' or command == '--help' then + printUsage(); + return; +end + +local ccpm = createCcpm(); + +local function logLine(msg) + print(msg); +end + +if command == 'install' or command == 'reinstall' then + local pkg = args[2]; + if not pkg then printUsage(); return; end + local ok, result = ccpm.install(pkg, { force = command == 'reinstall', log = logLine }); + if not ok then + print(result); + return; + end + print('=> ' .. pkg .. ' installed.'); + return; +end + +if command == 'uninstall' or command == 'remove' or command == 'rm' then + local pkg = args[2]; + if not pkg then printUsage(); return; end + local ok, err = ccpm.uninstall(pkg, { log = logLine }); + if not ok then + print(err); + return; + end + print('=> ' .. pkg .. ' uninstalled.'); + return; +end + +if command == 'ls' or command == 'list' then + local packages = ccpm.list(); + local names = {}; + for name in pairs(packages) do names[#names + 1] = name; end + table.sort(names); + if #names == 0 then + print('No packages installed.'); + return; + end + for _, name in ipairs(names) do + print(name .. ' v' .. tostring(packages[name].version or '?')); + end + return; +end + +if command == 'search' then + local results = ccpm.search(args[2]); + if #results == 0 then + print('No packages found.'); + return; + end + for _, r in ipairs(results) do + print(r.name .. ' v' .. tostring(r.version) .. ' (' .. r.registry .. ')'); + end + return; +end + +if command == 'info' then + local pkg = args[2]; + if not pkg then printUsage(); return; end + local desc = ccpm.info(pkg); + if not desc then + print('package not found: ' .. pkg); + return; + end + print(desc.name .. ' v' .. tostring(desc.version or '?')); + if desc.description then print(desc.description); end + if desc.dependencies and #desc.dependencies > 0 then + print('dependencies: ' .. table.concat(desc.dependencies, ', ')); + end + print('files:'); + for _, f in ipairs(desc.files or {}) do print(' ' .. f); end + return; +end + +if command == 'registry' then + local sub = args[2]; + + if sub == nil or sub == 'ls' or sub == 'list' then + local registries = ccpm.listRegistries(); + if #registries == 0 then + print('No registries configured.'); + return; + end + for _, r in ipairs(registries) do + if r.type == 'github' then + print(r.name .. ' (github:' .. tostring(r.branch or 'master') .. ')'); + else + print(r.name .. ' (' .. tostring(r.type or 'http') .. ')'); + end + end + return; + end + + if sub == 'add' then + local name = args[3]; + if not name then printUsage(); return; end + local branch, rtype; + local i = 4; + while i <= args.n do + local a = args[i]; + if a == '--branch' then + branch = args[i + 1]; + i = i + 1; + elseif a == '--type' then + rtype = args[i + 1]; + i = i + 1; + end + i = i + 1; + end + local ok, err = ccpm.addRegistry(name, { branch = branch, type = rtype }); + if not ok then + print(err); + return; + end + print('=> registry added: ' .. name); + return; + end + + if sub == 'rm' or sub == 'remove' then + local name = args[3]; + if not name then printUsage(); return; end + local ok, err = ccpm.removeRegistry(name); + if not ok then + print(err); + return; + end + print('=> registry removed: ' .. name); + return; + end + + printUsage(); + return; +end + +printUsage(); diff --git a/tests/ccpm.lua b/tests/ccpm.lua new file mode 100644 index 0000000..8577938 --- /dev/null +++ b/tests/ccpm.lua @@ -0,0 +1,182 @@ +local createLibTest = require('/apis/libtest'); +local createCcpm = require('/apis/libccpm'); + +local testlib = createLibTest({ ... }); + +local counter = 0; + +-- Fresh, isolated state + install sandbox per case (never touches real /trapos). +local function freshDirs() + counter = counter + 1; + local stateDir = '/ccpm-test/state-' .. counter; + local installRoot = '/ccpm-test/root-' .. counter; + fs.delete(stateDir); + fs.delete(installRoot); + return stateDir, installRoot; +end + +-- Minimal stub of the CC `http` API backed by a url -> body map. +local function fakeHttp(routes) + return { + get = function(url) + local body = routes[url]; + if not body then return nil; end + return { + readAll = function() return body; end, + close = function() end, + }; + end, + }; +end + +local function ghBase(name, branch) + return 'https://raw.githubusercontent.com/' .. name .. '/' .. branch .. '/'; +end + +testlib.test('registryBaseUrl resolves a github branch', function() + local ccpm = createCcpm({ stateDir = freshDirs() }); + testlib.assertEquals( + ccpm.registryBaseUrl({ name = 'guillaumearm/cc-libs', type = 'github', branch = 'next' }), + 'https://raw.githubusercontent.com/guillaumearm/cc-libs/next/' + ); +end); + +testlib.test('registry urls resolve an http base', function() + local ccpm = createCcpm({ stateDir = freshDirs() }); + testlib.assertEquals( + ccpm.registryBaseUrl({ name = 'http://example.com/repo', type = 'http' }), + 'http://example.com/repo/' + ); + testlib.assertEquals( + ccpm.descriptorUrl({ name = 'http://example.com/repo/', type = 'http' }, 'tos-net'), + 'http://example.com/repo/packages/tos-net/ccpm.json' + ); +end); + +testlib.test('resolve orders dependencies before dependents', function() + local base = ghBase('me/repo', 'master'); + local routes = { + [base .. 'packages/a/ccpm.json'] = textutils.serializeJSON({ name = 'a', version = '1', dependencies = { 'b', 'c' }, files = {} }), + [base .. 'packages/b/ccpm.json'] = textutils.serializeJSON({ name = 'b', version = '1', dependencies = { 'c' }, files = {} }), + [base .. 'packages/c/ccpm.json'] = textutils.serializeJSON({ name = 'c', version = '1', dependencies = {}, files = {} }), + }; + local ccpm = createCcpm({ stateDir = freshDirs(), http = fakeHttp(routes) }); + ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } }); + local ordered, err = ccpm.resolve('a'); + testlib.assertTrue(ordered, tostring(err)); + testlib.assertEquals(#ordered, 3); + testlib.assertEquals(ordered[1].name, 'c'); + testlib.assertEquals(ordered[2].name, 'b'); + testlib.assertEquals(ordered[3].name, 'a'); +end); + +testlib.test('resolve reports a missing package', function() + local ccpm = createCcpm({ stateDir = freshDirs(), http = fakeHttp({}) }); + ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } }); + local ordered, err = ccpm.resolve('ghost'); + testlib.assertTrue(not ordered); + testlib.assertTrue(string.find(err, 'not found', 1, true)); +end); + +testlib.test('resolve detects a dependency cycle', function() + local base = ghBase('me/repo', 'master'); + local routes = { + [base .. 'packages/a/ccpm.json'] = textutils.serializeJSON({ name = 'a', version = '1', dependencies = { 'b' } }), + [base .. 'packages/b/ccpm.json'] = textutils.serializeJSON({ name = 'b', version = '1', dependencies = { 'a' } }), + }; + local ccpm = createCcpm({ stateDir = freshDirs(), http = fakeHttp(routes) }); + ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } }); + local ordered, err = ccpm.resolve('a'); + testlib.assertTrue(not ordered); + testlib.assertTrue(string.find(err, 'cycle', 1, true)); +end); + +testlib.test('install rejects an already-installed package', function() + local ccpm = createCcpm({ stateDir = freshDirs() }); + ccpm.writeLock({ packages = { foo = { version = '1', files = {} } } }); + local ok, msg = ccpm.install('foo', {}); + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(msg, 'already installed', 1, true)); + testlib.assertTrue(string.find(msg, 'reinstall foo', 1, true)); +end); + +testlib.test('install downloads files and records the lock', function() + local base = ghBase('me/repo', 'master'); + local routes = { + [base .. 'packages/tos-core/ccpm.json'] = textutils.serializeJSON({ name = 'tos-core', version = '1', dependencies = {}, files = { 'apis/eventloop.lua' } }), + [base .. 'packages/tos-net/ccpm.json'] = textutils.serializeJSON({ name = 'tos-net', version = '1', dependencies = { 'tos-core' }, files = { 'apis/net.lua' } }), + [base .. 'apis/eventloop.lua'] = 'eventloop-body', + [base .. 'apis/net.lua'] = 'net-body', + }; + local sd, root = freshDirs(); + local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) }); + ccpm.writeConfig({ registries = { { name = 'me/repo', type = 'github', branch = 'master' } } }); + + local ok = ccpm.install('tos-net', {}); + testlib.assertTrue(ok); + testlib.assertTrue(fs.exists(root .. '/apis/net.lua')); + testlib.assertTrue(fs.exists(root .. '/apis/eventloop.lua')); + + local f = fs.open(root .. '/apis/net.lua', 'r'); + local body = f.readAll(); + f.close(); + testlib.assertEquals(body, 'net-body'); + + local lock = ccpm.readLock(); + testlib.assertTrue(lock.packages['tos-net']); + testlib.assertTrue(lock.packages['tos-core']); + testlib.assertEquals(lock.packages['tos-net'].registry, 'me/repo'); +end); + +testlib.test('registry add and remove round-trip', function() + local ccpm = createCcpm({ stateDir = freshDirs() }); + ccpm.writeConfig({ registries = {} }); + testlib.assertTrue(ccpm.addRegistry('foo/bar', { branch = 'next' })); + + local regs = ccpm.listRegistries(); + testlib.assertEquals(#regs, 1); + testlib.assertEquals(regs[1].name, 'foo/bar'); + testlib.assertEquals(regs[1].branch, 'next'); + + local dupOk = ccpm.addRegistry('foo/bar', {}); + testlib.assertTrue(not dupOk); + + testlib.assertTrue(ccpm.removeRegistry('foo/bar')); + testlib.assertEquals(#ccpm.listRegistries(), 0); + + local rmOk = ccpm.removeRegistry('nope'); + testlib.assertTrue(not rmOk); +end); + +testlib.test('uninstall refuses a package with dependents', function() + local ccpm = createCcpm({ stateDir = freshDirs() }); + ccpm.writeLock({ packages = { + ['tos-core'] = { version = '1', files = {}, dependencies = {} }, + ['tos-net'] = { version = '1', files = {}, dependencies = { 'tos-core' } }, + } }); + local ok, err = ccpm.uninstall('tos-core', {}); + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, 'required by', 1, true)); + testlib.assertTrue(string.find(err, 'tos-net', 1, true)); +end); + +testlib.test('uninstall removes a leaf package and its files', function() + local sd, root = freshDirs(); + fs.makeDir(root .. '/apis'); + local f = fs.open(root .. '/apis/net.lua', 'w'); + f.write('x'); + f.close(); + + local ccpm = createCcpm({ stateDir = sd, installRoot = root }); + ccpm.writeLock({ packages = { + ['tos-core'] = { version = '1', files = {}, dependencies = {} }, + ['tos-net'] = { version = '1', files = { 'apis/net.lua' }, dependencies = { 'tos-core' } }, + } }); + + testlib.assertTrue(ccpm.uninstall('tos-net', {})); + testlib.assertTrue(not fs.exists(root .. '/apis/net.lua')); + testlib.assertTrue(ccpm.readLock().packages['tos-net'] == nil); + testlib.assertTrue(ccpm.readLock().packages['tos-core'] ~= nil); +end); + +testlib.run();