From cb6ceaf575e9050734f1d917a87326199d879219 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Mon, 15 Jun 2026 21:42:16 +0200 Subject: [PATCH] feat(installer): add TrapOS disk modes --- apis/libccpm.lua | 158 ++++++++++++++++-- apis/libinstallerdisk.lua | 151 +++++++++++++---- install-trapos.lua | 9 +- manifest.json | 2 +- packages/index.json | 4 +- packages/trapos-core/ccpm.json | 4 +- packages/trapos/ccpm.json | 2 +- programs/ccpm.lua | 89 +++++++++- programs/trapos-create-disk.lua | 87 ++++++++++ programs/trapos-create-installer-disk.lua | 54 ------ tests/ccpm.lua | 140 ++++++++++++++++ tests/{installer-disk.lua => create-disk.lua} | 77 +++++++-- tests/install-trapos.lua | 10 ++ 13 files changed, 668 insertions(+), 119 deletions(-) create mode 100644 programs/trapos-create-disk.lua delete mode 100644 programs/trapos-create-installer-disk.lua rename tests/{installer-disk.lua => create-disk.lua} (62%) diff --git a/apis/libccpm.lua b/apis/libccpm.lua index c1f5850..2db94c8 100644 --- a/apis/libccpm.lua +++ b/apis/libccpm.lua @@ -5,7 +5,8 @@ -- State lives under `opts.stateDir` (default `/trapos`): -- - ccpm.json -> { registries = { { name, type, branch }, ... } } -- - ccpm.lock.json -> { packages = { = { version, registry, files, --- dependencies, autostart } } } +-- dependencies, autostart, +-- explicit } } } -- - ccpm.cache.json -> { packages = { = { version, registry } } } -- -- Files are written under `opts.installRoot` (default '' -> filesystem root), so @@ -127,6 +128,58 @@ local function createCcpm(opts) return api.readLock().packages or {}; end + local function dependentsOf(lock, pkg) + local dependents = {}; + for name, entry in pairs(lock.packages or {}) do + if name ~= pkg then + for _, dep in ipairs(entry.dependencies or {}) do + if dep == pkg then dependents[#dependents + 1] = name; end + end + end + end + table.sort(dependents); + return dependents; + end + + local function copyLockWithout(lock, pkg) + local packages = {}; + for name, entry in pairs(lock.packages or {}) do + if name ~= pkg then + packages[name] = entry; + end + end + return { packages = packages }; + end + + local function markReachable(lock, name, reachable) + if reachable[name] then return; end + local entry = lock.packages and lock.packages[name]; + if not entry then return; end + reachable[name] = true; + for _, dep in ipairs(entry.dependencies or {}) do + markReachable(lock, dep, reachable); + end + end + + local function pruneCandidates(lock) + local reachable = {}; + for name, entry in pairs(lock.packages or {}) do + -- Missing `explicit` is legacy state and remains a root. + if entry.explicit ~= false then + markReachable(lock, name, reachable); + end + end + + local names = {}; + for name in pairs(lock.packages or {}) do + if not reachable[name] then + names[#names + 1] = name; + end + end + table.sort(names); + return names; + end + local function writeOsState(lock) local files = {}; local seenFile = {}; @@ -350,6 +403,23 @@ local function createCcpm(opts) return normalizePath(installRoot .. '/' .. filePath); end + local function cleanupEmptyGeneratedDirs() + for _, dir in ipairs({ 'programs', 'apis', 'servers', 'startup' }) do + local target = installTarget(dir); + if target ~= '/' and fs.exists(target) and fs.isDir(target) and #fs.list(target) == 0 then + fs.delete(target); + end + end + end + + local function cleanupStateIfEmpty(lock) + if next(lock.packages or {}) then return; end + if normalizePath(stateDir) ~= '/' then + fs.delete(stateDir); + end + cleanupEmptyGeneratedDirs(); + end + function api.downloadFile(registry, filePath) local body = httpGetBody(api.fileUrl(registry, filePath)); if not body then @@ -394,12 +464,14 @@ local function createCcpm(opts) local ok, derr = api.downloadFile(item.registry, filePath); if not ok then return false, derr; end end + local previous = lock.packages[item.name]; 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 {}, + explicit = isTarget or (previous and previous.explicit == true) or false, }; end end @@ -435,7 +507,68 @@ local function createCcpm(opts) return true, names; end - -- uninstallOpts: { force = bool, log = function(msg) } + -- pruneOpts: { dryRun = bool, log = function(msg) } + function api.prune(pruneOpts) + pruneOpts = pruneOpts or {}; + local log = pruneOpts.log or function() end; + local lock = api.readLock(); + lock.packages = lock.packages or {}; + local names = pruneCandidates(lock); + + if pruneOpts.dryRun then + return names; + end + + if #names == 0 then + cleanupStateIfEmpty(lock); + return names; + end + + for _, name in ipairs(names) do + local entry = lock.packages[name]; + if entry then + log('prune ' .. name); + for _, filePath in ipairs(entry.files or {}) do + log('remove ' .. filePath); + fs.delete(installTarget(filePath)); + end + end + end + for _, name in ipairs(names) do + lock.packages[name] = nil; + end + api.writeLock(lock); + writeOsState(lock); + cleanupStateIfEmpty(lock); + return names; + end + + -- uninstallOpts: { force = bool, prune = bool } + function api.planUninstall(pkg, uninstallOpts) + uninstallOpts = uninstallOpts or {}; + local lock = api.readLock(); + lock.packages = lock.packages or {}; + + if not lock.packages[pkg] then + return false, 'package not installed: ' .. pkg; + end + + local dependents = dependentsOf(lock, pkg); + if #dependents > 0 and not uninstallOpts.force then + return false, 'cannot uninstall ' .. pkg .. ': required by ' .. table.concat(dependents, ', '); + end + + local removed = { pkg }; + if uninstallOpts.prune ~= false then + local previewLock = copyLockWithout(lock, pkg); + for _, name in ipairs(pruneCandidates(previewLock)) do + removed[#removed + 1] = name; + end + end + return true, removed; + end + + -- uninstallOpts: { force = bool, prune = bool, log = function(msg) } function api.uninstall(pkg, uninstallOpts) uninstallOpts = uninstallOpts or {}; local log = uninstallOpts.log or function() end; @@ -447,16 +580,8 @@ local function createCcpm(opts) 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 + local dependents = dependentsOf(lock, pkg); if #dependents > 0 and not uninstallOpts.force then - table.sort(dependents); return false, 'cannot uninstall ' .. pkg .. ': required by ' .. table.concat(dependents, ', '); end @@ -467,7 +592,16 @@ local function createCcpm(opts) lock.packages[pkg] = nil; api.writeLock(lock); writeOsState(lock); - return true; + + local removed = { pkg }; + if uninstallOpts.prune ~= false then + for _, name in ipairs(api.prune({ log = log })) do + removed[#removed + 1] = name; + end + else + cleanupStateIfEmpty(lock); + end + return true, removed; end return api; diff --git a/apis/libinstallerdisk.lua b/apis/libinstallerdisk.lua index 8d44a0d..5fb3295 100644 --- a/apis/libinstallerdisk.lua +++ b/apis/libinstallerdisk.lua @@ -1,7 +1,15 @@ -local DEFAULT_INSTALLER_LABEL = 'TrapOS Installer'; +local DEFAULT_DISK_LABEL = 'TrapOS Disk'; local DEFAULT_INSTALL_URL = 'https://os.trapcloud.fr/install'; local DEFAULT_SETTINGS_SOURCE_PATH = '/.settings'; +local function luaQuote(value) + return string.format('%q', tostring(value)); +end + +local function isValidMode(mode) + return mode == 'install' or mode == 'reinstall' or mode == 'uninstall'; +end + local function writeFile(path, body) local file = fs.open(path, 'w'); if not file then return false; end @@ -15,41 +23,121 @@ local function createInstallerDisk(opts) local peripheralApi = opts.peripheral or peripheral; local settingsApi = opts.settings or settings; local settingsSourcePath = opts.settingsSourcePath or DEFAULT_SETTINGS_SOURCE_PATH; - local installerLabel = opts.installerLabel or DEFAULT_INSTALLER_LABEL; + local diskLabel = opts.diskLabel or opts.installerLabel or DEFAULT_DISK_LABEL; local installUrl = opts.installUrl or DEFAULT_INSTALL_URL; local api = {}; - function api.buildAutorun() - return '-- TrapOS installer disk autorun (generated; do not edit)\n' - .. "local INSTALLER_LABEL = '" .. installerLabel .. "';\n" - .. "local INSTALL_URL = '" .. installUrl .. "';\n" - .. '\n' - .. "if fs.exists('/trapos') then return; end\n" - .. '\n' - .. 'local function installerMount()\n' + local function buildHeader(mode) + return '-- TrapOS ' .. mode .. ' disk autorun (generated; do not edit)\n' + .. 'local DISK_LABEL = ' .. luaQuote(diskLabel) .. ';\n' + .. 'local INSTALL_URL = ' .. luaQuote(installUrl) .. ';\n' + .. '\n'; + end + + local function buildDiskMountFunction() + return 'local function diskMount()\n' .. ' for _, name in ipairs(peripheral.getNames()) do\n' .. " if peripheral.getType(name) == 'drive' then\n" .. ' local drive = peripheral.wrap(name);\n' - .. ' if drive.isDiskPresent() and drive.getDiskLabel() == INSTALLER_LABEL then\n' + .. ' if drive.isDiskPresent() and drive.getDiskLabel() == DISK_LABEL then\n' .. ' return drive.getMountPath();\n' .. ' end\n' .. ' end\n' .. ' end\n' .. 'end\n' - .. '\n' - .. 'local mount = installerMount();\n' - .. 'if mount then\n' - .. " local src = fs.combine(mount, '.settings');\n" - .. ' if fs.exists(src) then\n' - .. " fs.delete('/.settings');\n" - .. " fs.copy(src, '/.settings');\n" - .. ' settings.load();\n' + .. '\n'; + end + + local function buildWriteMarkerFunction() + return 'local function writeMarker(path)\n' + .. " local file = fs.open(path, 'w');\n" + .. ' if not file then return false; end\n' + .. " file.write('1');\n" + .. ' file.close();\n' + .. ' return true;\n' + .. 'end\n' + .. '\n'; + end + + function api.buildAutorun(mode) + mode = mode or 'install'; + if not isValidMode(mode) then + error('invalid disk mode: ' .. tostring(mode), 2); + end + + if mode == 'install' then + return buildHeader(mode) + .. "if fs.exists('/trapos') then return; end\n" + .. '\n' + .. buildDiskMountFunction() + .. 'local mount = diskMount();\n' + .. 'if mount then\n' + .. " local src = fs.combine(mount, '.settings');\n" + .. ' if fs.exists(src) then\n' + .. " fs.delete('/.settings');\n" + .. " fs.copy(src, '/.settings');\n" + .. ' settings.load();\n' + .. ' end\n' + .. 'end\n' + .. '\n' + .. "print('TrapOS Disk: installing TrapOS...');\n" + .. "if not shell.run('wget', 'run', INSTALL_URL) then\n" + .. " print('TrapOS install failed.');\n" + .. 'end\n'; + end + + if mode == 'uninstall' then + return buildHeader(mode) + .. "if not fs.exists('/trapos') then\n" + .. " print('TrapOS already removed.');\n" + .. 'else\n' + .. " print('TrapOS Disk: uninstalling TrapOS...');\n" + .. " if not shell.run('ccpm', 'uninstall', 'trapos', '--yes') then\n" + .. " print('TrapOS uninstall failed.');\n" + .. ' return;\n' + .. ' end\n' + .. 'end\n' + .. '\n' + .. "print('TrapOS removed. Eject the disk to finish.');\n" + .. "os.pullEvent('disk_eject');\n" + .. 'os.reboot();\n'; + end + + return buildHeader(mode) + .. buildDiskMountFunction() + .. buildWriteMarkerFunction() + .. 'local mount = diskMount();\n' + .. 'if not mount then\n' + .. " print('TrapOS Disk not found.');\n" + .. ' return;\n' + .. 'end\n' + .. "local MARKER = fs.combine(mount, '.trapos-reinstalled');\n" + .. "if fs.exists(MARKER) and fs.exists('/trapos') then\n" + .. " print('Reinstall complete. Eject the disk.');\n" + .. " os.pullEvent('disk_eject');\n" + .. ' os.reboot();\n' + .. ' return;\n' + .. 'elseif fs.exists(MARKER) then\n' + .. ' fs.delete(MARKER);\n' + .. 'end\n' + .. "if fs.exists('/trapos') then\n" + .. " print('TrapOS Disk: removing current TrapOS...');\n" + .. " if not shell.run('ccpm', 'uninstall', 'trapos', '--yes') then\n" + .. " print('TrapOS uninstall failed.');\n" + .. ' return;\n' .. ' end\n' .. 'end\n' - .. '\n' - .. "print('TrapOS Installer: installing TrapOS...');\n" - .. "shell.run('wget', 'run', INSTALL_URL);\n"; + .. 'if not writeMarker(MARKER) then\n' + .. " print('Failed to write reinstall marker.');\n" + .. ' return;\n' + .. 'end\n' + .. "print('TrapOS Disk: reinstalling TrapOS...');\n" + .. "if not shell.run('wget', 'run', INSTALL_URL) then\n" + .. ' fs.delete(MARKER);\n' + .. " print('TrapOS install failed.');\n" + .. ' return;\n' + .. 'end\n'; end function api.findTargetDrive() @@ -85,33 +173,40 @@ local function createInstallerDisk(opts) return false, 'insert a blank floppy'; end - function api.create() + function api.create(createOpts) + createOpts = createOpts or {}; + local mode = createOpts.mode or 'install'; + if not isValidMode(mode) then + return false, 'invalid disk mode: ' .. tostring(mode); + end + local ok, target = api.findTargetDrive(); if not ok then return false, target; end - target.drive.setDiskLabel(installerLabel); + target.drive.setDiskLabel(diskLabel); local result = { name = target.name, mountPath = target.mountPath, - label = installerLabel, + label = diskLabel, + mode = mode, settingsCopied = false, settingsWarning = false, }; - if fs.exists(settingsSourcePath) then + if mode == 'install' and fs.exists(settingsSourcePath) then settingsApi.save(); local dest = fs.combine(target.mountPath, '.settings'); fs.delete(dest); fs.copy(settingsSourcePath, dest); result.settingsCopied = true; - else + elseif mode == 'install' then result.settingsWarning = true; end local startupPath = fs.combine(target.mountPath, 'startup.lua'); fs.delete(startupPath); - if not writeFile(startupPath, api.buildAutorun()) then + if not writeFile(startupPath, api.buildAutorun(mode)) then return false, 'failed to write ' .. startupPath; end diff --git a/install-trapos.lua b/install-trapos.lua index 894d645..47fe132 100644 --- a/install-trapos.lua +++ b/install-trapos.lua @@ -1,4 +1,4 @@ -local _VERSION = '6.0.1'; +local _VERSION = '6.1.0'; local REPO_BASE = 'https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/master/'; local LOCAL_STATE_DIR = '/trapos'; @@ -140,6 +140,12 @@ if not manifest then end local requested = { 'trapos-core' }; +local explicitBootstrap = {}; +if cpmOnly then + for _, name in ipairs(requested) do + explicitBootstrap[name] = true; + end +end local resolved, resolveErr = resolvePackages(requested); if not resolved then @@ -194,6 +200,7 @@ for _, desc in ipairs(resolved) do files = desc.files or {}, dependencies = desc.dependencies or {}, autostart = desc.autostart or {}, + explicit = explicitBootstrap[desc.name] == true, }; end diff --git a/manifest.json b/manifest.json index 35e1208..556cee3 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.10.0", + "version": "0.11.0", "branch": "master", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index 85d14e8..503acbb 100644 --- a/packages/index.json +++ b/packages/index.json @@ -1,6 +1,6 @@ { "packages": { - "trapos-core": "0.8.0", + "trapos-core": "0.9.0", "trapos-test": "0.2.1", "trapos-boot": "0.4.0", "trapos-net": "0.3.0", @@ -8,6 +8,6 @@ "trapos-ai": "0.7.0", "trapos-sandbox": "0.3.0", "trapos-sandbox-legacy": "0.3.0", - "trapos": "0.10.0" + "trapos": "0.11.0" } } diff --git a/packages/trapos-core/ccpm.json b/packages/trapos-core/ccpm.json index e4ebe6e..56cbdf2 100644 --- a/packages/trapos-core/ccpm.json +++ b/packages/trapos-core/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos-core", - "version": "0.8.0", + "version": "0.9.0", "description": "TrapOS base: package manager, event loop, upgrade and event tools", "dependencies": [], "files": [ @@ -9,7 +9,7 @@ "apis/libinstallerdisk.lua", "apis/libversion.lua", "programs/ccpm.lua", - "programs/trapos-create-installer-disk.lua", + "programs/trapos-create-disk.lua", "programs/trapos-upgrade.lua", "programs/trapos-postinstall.lua", "programs/events.lua" diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index 98658c4..14f23e2 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos", - "version": "0.10.0", + "version": "0.11.0", "description": "TrapOS full install meta-package", "dependencies": [ "trapos-boot", diff --git a/programs/ccpm.lua b/programs/ccpm.lua index 318ac0f..233ba29 100644 --- a/programs/ccpm.lua +++ b/programs/ccpm.lua @@ -9,7 +9,8 @@ local function printUsage() print(); print('\t\tccpm install '); print('\t\tccpm reinstall '); - print('\t\tccpm uninstall '); + print('\t\tccpm uninstall [--yes] [--no-prune]'); + print('\t\tccpm prune'); print('\t\tccpm update'); print('\t\tccpm upgrade'); print('\t\tccpm ls'); @@ -50,6 +51,44 @@ local function printColored(msg, color) end end +local function readLine() + if type(read) == 'function' then return read(); end + if io and type(io.read) == 'function' then return io.read(); end + return ''; +end + +local function confirm(prompt) + print(prompt .. ' [y/N]'); + local answer = string.lower(readLine() or ''); + return answer == 'y' or answer == 'yes'; +end + +local function parseUninstallArgs(rawArgs) + local pkg = nil; + local yes = false; + local noPrune = false; + local parseError = nil; + + local i = 2; + while i <= rawArgs.n do + local arg = rawArgs[i]; + if arg == '--yes' or arg == '-y' then + yes = true; + elseif arg == '--no-prune' then + noPrune = true; + elseif arg and string.sub(arg, 1, 1) == '-' then + parseError = 'unknown option: ' .. arg; + elseif not pkg then + pkg = arg; + else + parseError = 'unexpected argument: ' .. arg; + end + i = i + 1; + end + + return pkg, { yes = yes, prune = not noPrune }, parseError; +end + local function printAvailableRow(r) local installed = ''; if r.installedVersion then @@ -99,15 +138,51 @@ if command == 'upgrade' then return; end +if command == 'prune' then + local removed = ccpm.prune({ log = logLine }); + if #removed == 0 then + print('=> nothing to prune.'); + else + print('=> pruned: ' .. table.concat(removed, ', ')); + end + 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); + local pkg, uninstallOpts, parseError = parseUninstallArgs(args); + if parseError then + print(parseError); + printUsage(); return; end - print('=> ' .. pkg .. ' uninstalled.'); + if not pkg then printUsage(); return; end + + if not uninstallOpts.yes then + local planOk, plan = ccpm.planUninstall(pkg, { prune = uninstallOpts.prune }); + if not planOk then + print(plan); + return; + end + print('The following packages will be removed:'); + for _, name in ipairs(plan) do + print(' ' .. name); + end + if not confirm('Continue?') then + print('=> uninstall cancelled.'); + return; + end + end + + local ok, result = ccpm.uninstall(pkg, { prune = uninstallOpts.prune, log = logLine }); + if not ok then + print(result); + return; + end + if type(result) == 'table' and #result > 1 then + print('=> removed: ' .. table.concat(result, ', ')); + else + print('=> ' .. pkg .. ' uninstalled.'); + end return; end diff --git a/programs/trapos-create-disk.lua b/programs/trapos-create-disk.lua new file mode 100644 index 0000000..301f8b3 --- /dev/null +++ b/programs/trapos-create-disk.lua @@ -0,0 +1,87 @@ +local createInstallerDisk = require('/apis/libinstallerdisk'); +local createVersion = require('/apis/libversion'); + +local function printUsage() + print('trapos-create-disk usage:'); + print(); + print('\t\ttrapos-create-disk --install'); + print('\t\ttrapos-create-disk --reinstall'); + print('\t\ttrapos-create-disk --uninstall'); + print('\t\ttrapos-create-disk version'); + print('\t\ttrapos-create-disk help'); +end + +local function printColored(msg, color) + if term.isColor and term.isColor() then + local previous = term.getTextColor(); + term.setTextColor(color); + print(msg); + term.setTextColor(previous); + else + print(msg); + end +end + +local rawArgs = table.pack(...); + +if rawArgs.n == 1 and (rawArgs[1] == 'version' or rawArgs[1] == '-version' or rawArgs[1] == '--version') then + print('v' .. createVersion().forSelf()); + return; +end + +if rawArgs.n == 0 or (rawArgs.n == 1 and (rawArgs[1] == 'help' or rawArgs[1] == '-help' or rawArgs[1] == '--help')) then + printUsage(); + return; +end + +local mode = nil; +local parseError = nil; + +local function selectMode(nextMode) + if mode and mode ~= nextMode then + parseError = 'choose only one disk mode'; + else + mode = nextMode; + end +end + +for i = 1, rawArgs.n do + local arg = rawArgs[i]; + if arg == '--install' then + selectMode('install'); + elseif arg == '--reinstall' then + selectMode('reinstall'); + elseif arg == '--uninstall' then + selectMode('uninstall'); + else + parseError = 'unknown argument: ' .. tostring(arg); + end +end + +if parseError or not mode then + if parseError then printColored(parseError, colors.red); end + printUsage(); + return; +end + +local ok, result = createInstallerDisk().create({ mode = mode }); +if not ok then + printColored(result, colors.red); + return; +end + +printColored('=> TrapOS ' .. mode .. ' disk created on ' .. result.name .. '.', colors.lime); +print('Label: ' .. result.label); +if result.settingsCopied then + print('Cloned /.settings to ' .. fs.combine(result.mountPath, '.settings')); +elseif result.settingsWarning then + printColored('Warning: /.settings not found; install disk has no cloned settings.', colors.orange); +end + +if mode == 'install' then + print('Insert this disk into a blank computer and reboot it.'); +elseif mode == 'reinstall' then + print('Insert this disk into the target computer and reboot it to reinstall TrapOS.'); +else + print('Insert this disk into the target computer and reboot it to remove TrapOS.'); +end diff --git a/programs/trapos-create-installer-disk.lua b/programs/trapos-create-installer-disk.lua deleted file mode 100644 index fdf1fe6..0000000 --- a/programs/trapos-create-installer-disk.lua +++ /dev/null @@ -1,54 +0,0 @@ -local createInstallerDisk = require('/apis/libinstallerdisk'); -local createVersion = require('/apis/libversion'); - -local function printUsage() - print('trapos-create-installer-disk usage:'); - print(); - print('\t\ttrapos-create-installer-disk'); - print('\t\ttrapos-create-installer-disk version'); - print('\t\ttrapos-create-installer-disk help'); -end - -local function printColored(msg, color) - if term.isColor and term.isColor() then - local previous = term.getTextColor(); - term.setTextColor(color); - print(msg); - term.setTextColor(previous); - else - print(msg); - end -end - -local args = table.pack(...); -local command = args[1]; - -if command == 'version' or command == '-version' or command == '--version' then - print('v' .. createVersion().forSelf()); - return; -end - -if command == 'help' or command == '-help' or command == '--help' then - printUsage(); - return; -end - -if command ~= nil and command ~= '' then - printUsage(); - return; -end - -local ok, result = createInstallerDisk().create(); -if not ok then - printColored(result, colors.red); - return; -end - -printColored('=> TrapOS installer disk created on ' .. result.name .. '.', colors.lime); -print('Label: ' .. result.label); -if result.settingsCopied then - print('Cloned /.settings to ' .. fs.combine(result.mountPath, '.settings')); -elseif result.settingsWarning then - printColored('Warning: /.settings not found; installer disk has no cloned settings.', colors.orange); -end -print('Insert this disk into a blank computer and reboot it.'); diff --git a/tests/ccpm.lua b/tests/ccpm.lua index af912ee..5f87094 100644 --- a/tests/ccpm.lua +++ b/tests/ccpm.lua @@ -37,6 +37,14 @@ local function giteaBase(name, branch) return "https://git.trapcloud.fr/" .. name .. "/raw/branch/" .. branch .. "/" end +local function writeFile(path, body) + local dir = path:match("^(.*)/[^/]+$"); + if dir then fs.makeDir(dir); end + local f = fs.open(path, "w"); + f.write(body); + f.close(); +end + testlib.test("registryBaseUrl resolves a gitea branch", function() local ccpm = createCcpm({ stateDir = freshDirs() }) testlib.assertEquals( @@ -167,6 +175,8 @@ testlib.test("install downloads files and records the lock", function() testlib.assertTrue(lock.packages["trapos-net"]) testlib.assertTrue(lock.packages["trapos-core"]) testlib.assertEquals(lock.packages["trapos-net"].registry, "me/repo") + testlib.assertEquals(lock.packages["trapos-net"].explicit, true) + testlib.assertEquals(lock.packages["trapos-core"].explicit, false) end) testlib.test("install downloads files from a gitea registry", function() @@ -377,6 +387,136 @@ testlib.test("upgrade requires a package cache", function() testlib.assertTrue(string.find(err, "ccpm update", 1, true)) end) +testlib.test("prune removes orphans and keeps explicit roots", function() + local sd, root = freshDirs(); + writeFile(root .. "/programs/app.lua", "app"); + writeFile(root .. "/apis/dep.lua", "dep"); + writeFile(root .. "/programs/orphan.lua", "orphan"); + writeFile(root .. "/apis/orphan-dep.lua", "orphan-dep"); + + local ccpm = createCcpm({ stateDir = sd, installRoot = root }); + ccpm.writeLock({ + packages = { + app = { version = "1", files = { "programs/app.lua" }, dependencies = { "dep" }, explicit = true }, + dep = { version = "1", files = { "apis/dep.lua" }, dependencies = {}, explicit = false }, + orphan = { version = "1", files = { "programs/orphan.lua" }, dependencies = { "orphan-dep" }, explicit = false }, + ["orphan-dep"] = { version = "1", files = { "apis/orphan-dep.lua" }, dependencies = {}, explicit = false }, + }, + }); + + local preview = ccpm.prune({ dryRun = true }); + testlib.assertEquals(#preview, 2); + testlib.assertEquals(preview[1], "orphan"); + testlib.assertEquals(preview[2], "orphan-dep"); + testlib.assertTrue(fs.exists(root .. "/programs/orphan.lua")); + + local removed = ccpm.prune({}); + testlib.assertEquals(#removed, 2); + testlib.assertTrue(ccpm.readLock().packages.app ~= nil); + testlib.assertTrue(ccpm.readLock().packages.dep ~= nil); + testlib.assertTrue(ccpm.readLock().packages.orphan == nil); + testlib.assertTrue(not fs.exists(root .. "/programs/orphan.lua")); + testlib.assertTrue(not fs.exists(root .. "/apis/orphan-dep.lua")); + testlib.assertTrue(fs.exists(root .. "/programs/app.lua")); + testlib.assertTrue(fs.exists(root .. "/apis/dep.lua")); +end) + +testlib.test("uninstall autoprunes orphaned dependencies", function() + local sd, root = freshDirs(); + writeFile(root .. "/apis/core.lua", "core"); + writeFile(root .. "/programs/feature.lua", "feature"); + + local ccpm = createCcpm({ stateDir = sd, installRoot = root }); + ccpm.writeLock({ + packages = { + trapos = { version = "1", files = {}, dependencies = { "feature" }, explicit = true }, + feature = { version = "1", files = { "programs/feature.lua" }, dependencies = { "core" }, explicit = false }, + core = { version = "1", files = { "apis/core.lua" }, dependencies = {}, explicit = false }, + }, + }); + + local ok, removed = ccpm.uninstall("trapos", {}); + testlib.assertTrue(ok); + testlib.assertEquals(#removed, 3); + testlib.assertEquals(removed[1], "trapos"); + testlib.assertEquals(removed[2], "core"); + testlib.assertEquals(removed[3], "feature"); + testlib.assertTrue(not fs.exists(root .. "/apis/core.lua")); + testlib.assertTrue(not fs.exists(root .. "/programs/feature.lua")); + testlib.assertTrue(not fs.exists(sd)); +end) + +testlib.test("uninstall can skip pruning", function() + local sd, root = freshDirs(); + writeFile(root .. "/apis/core.lua", "core"); + + local ccpm = createCcpm({ stateDir = sd, installRoot = root }); + ccpm.writeLock({ + packages = { + trapos = { version = "1", files = {}, dependencies = { "core" }, explicit = true }, + core = { version = "1", files = { "apis/core.lua" }, dependencies = {}, explicit = false }, + }, + }); + + local ok, removed = ccpm.uninstall("trapos", { prune = false }); + testlib.assertTrue(ok); + testlib.assertEquals(#removed, 1); + testlib.assertTrue(ccpm.readLock().packages.trapos == nil); + testlib.assertTrue(ccpm.readLock().packages.core ~= nil); + testlib.assertTrue(fs.exists(root .. "/apis/core.lua")); + testlib.assertTrue(fs.exists(sd)); +end) + +testlib.test("uninstalling the last package deletes state and empty generated dirs", function() + local sd, root = freshDirs(); + writeFile(root .. "/programs/solo.lua", "solo"); + + local ccpm = createCcpm({ stateDir = sd, installRoot = root }); + ccpm.writeLock({ + packages = { + solo = { version = "1", files = { "programs/solo.lua" }, dependencies = {}, explicit = true }, + }, + }); + + testlib.assertTrue(ccpm.uninstall("solo", {})); + testlib.assertTrue(not fs.exists(root .. "/programs/solo.lua")); + testlib.assertTrue(not fs.exists(root .. "/programs")); + testlib.assertTrue(not fs.exists(sd)); +end) + +testlib.test("legacy entries without explicit are not pruned", function() + local ccpm = createCcpm({ stateDir = freshDirs() }); + ccpm.writeLock({ + packages = { + legacy = { version = "1", files = {}, dependencies = { "dep" } }, + dep = { version = "1", files = {}, dependencies = {}, explicit = false }, + orphan = { version = "1", files = {}, dependencies = {}, explicit = false }, + }, + }); + + local removed = ccpm.prune({}); + testlib.assertEquals(#removed, 1); + testlib.assertEquals(removed[1], "orphan"); + testlib.assertTrue(ccpm.readLock().packages.legacy ~= nil); + testlib.assertTrue(ccpm.readLock().packages.dep ~= nil); +end) + +testlib.test("planUninstall previews autoprune removals", function() + local ccpm = createCcpm({ stateDir = freshDirs() }); + ccpm.writeLock({ + packages = { + trapos = { version = "1", files = {}, dependencies = { "core" }, explicit = true }, + core = { version = "1", files = {}, dependencies = {}, explicit = false }, + }, + }); + + local ok, planned = ccpm.planUninstall("trapos", {}); + testlib.assertTrue(ok); + testlib.assertEquals(#planned, 2); + testlib.assertEquals(planned[1], "trapos"); + testlib.assertEquals(planned[2], "core"); +end) + testlib.test("uninstall refuses a package with dependents", function() local ccpm = createCcpm({ stateDir = freshDirs() }) ccpm.writeLock({ diff --git a/tests/installer-disk.lua b/tests/create-disk.lua similarity index 62% rename from tests/installer-disk.lua rename to tests/create-disk.lua index 352444f..80d3eff 100644 --- a/tests/installer-disk.lua +++ b/tests/create-disk.lua @@ -7,7 +7,7 @@ local counter = 0; local function freshDir() counter = counter + 1; - local path = '/installer-disk-test/case-' .. counter; + local path = '/create-disk-test/case-' .. counter; fs.delete(path); fs.makeDir(path); return path; @@ -76,11 +76,17 @@ local function fakeSettings() return api; end -testlib.test('buildAutorun copies settings before wget and no reboot', function() +local function assertCompiles(body) + local chunk, err = load(body, 'generated-startup'); + testlib.assertTrue(chunk, tostring(err)); +end + +testlib.test('install autorun copies settings before wget and no reboot', function() local installer = createInstallerDisk(); - local body = installer.buildAutorun(); + local body = installer.buildAutorun('install'); + assertCompiles(body); local sentinel = string.find(body, "fs.exists('/trapos')", 1, true); - local label = string.find(body, 'TrapOS Installer', 1, true); + local label = string.find(body, 'TrapOS Disk', 1, true); local url = string.find(body, 'https://os.trapcloud.fr/install', 1, true); local copy = string.find(body, "fs.copy(src, '/.settings')", 1, true); local wget = string.find(body, "shell.run('wget', 'run', INSTALL_URL)", 1, true); @@ -94,6 +100,30 @@ testlib.test('buildAutorun copies settings before wget and no reboot', function( testlib.assertTrue(not string.find(body, 'os.reboot', 1, true)); end); +testlib.test('uninstall autorun removes trapos and waits for eject', function() + local installer = createInstallerDisk(); + local body = installer.buildAutorun('uninstall'); + assertCompiles(body); + + testlib.assertTrue(string.find(body, "shell.run('ccpm', 'uninstall', 'trapos', '--yes')", 1, true)); + testlib.assertTrue(string.find(body, "os.pullEvent('disk_eject')", 1, true)); + testlib.assertTrue(string.find(body, 'os.reboot', 1, true)); + testlib.assertTrue(not string.find(body, "shell.run('wget'", 1, true)); + testlib.assertTrue(not string.find(body, "fs.copy(src, '/.settings')", 1, true)); +end); + +testlib.test('reinstall autorun uses marker, uninstall and wget', function() + local installer = createInstallerDisk(); + local body = installer.buildAutorun('reinstall'); + assertCompiles(body); + + testlib.assertTrue(string.find(body, '.trapos-reinstalled', 1, true)); + testlib.assertTrue(string.find(body, 'writeMarker(MARKER)', 1, true)); + testlib.assertTrue(string.find(body, "shell.run('ccpm', 'uninstall', 'trapos', '--yes')", 1, true)); + testlib.assertTrue(string.find(body, "shell.run('wget', 'run', INSTALL_URL)", 1, true)); + testlib.assertTrue(string.find(body, "os.pullEvent('disk_eject')", 1, true)); +end); + testlib.test('findTargetDrive asks for blank disk with zero drives', function() local installer = createInstallerDisk({ peripheral = fakePeripheral({}) }); local ok, err = installer.findTargetDrive(); @@ -140,7 +170,7 @@ testlib.test('findTargetDrive rejects multiple empty disks', function() testlib.assertTrue(string.find(err, 'remove all but one', 1, true)); end); -testlib.test('create labels disk, copies settings, writes startup', function() +testlib.test('create install disk labels, copies settings, writes startup', function() local disk = freshDir(); local settingsPath = fs.combine(freshDir(), '.settings'); local drive = { name = 'top', mountPath = disk }; @@ -152,18 +182,19 @@ testlib.test('create labels disk, copies settings, writes startup', function() settings = settingsApi, settingsSourcePath = settingsPath, }); - local ok, result = installer.create(); + local ok, result = installer.create({ mode = 'install' }); testlib.assertTrue(ok, tostring(result)); - testlib.assertEquals(drive.label, 'TrapOS Installer'); + testlib.assertEquals(drive.label, 'TrapOS Disk'); + testlib.assertEquals(result.mode, 'install'); testlib.assertEquals(settingsApi.saveCount, 1); - testlib.assertEquals(readAll(fs.combine(disk, 'startup.lua')), installer.buildAutorun()); + testlib.assertEquals(readAll(fs.combine(disk, 'startup.lua')), installer.buildAutorun('install')); testlib.assertEquals(readAll(fs.combine(disk, '.settings')), 'sandbox.url=https://example.test'); testlib.assertTrue(result.settingsCopied); testlib.assertTrue(not result.settingsWarning); end); -testlib.test('create warns when settings source is missing', function() +testlib.test('create install disk warns when settings source is missing', function() local disk = freshDir(); local settingsApi = fakeSettings(); local installer = createInstallerDisk({ @@ -171,14 +202,38 @@ testlib.test('create warns when settings source is missing', function() settings = settingsApi, settingsSourcePath = fs.combine(freshDir(), '.settings'), }); - local ok, result = installer.create(); + local ok, result = installer.create({ mode = 'install' }); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(settingsApi.saveCount, 0); testlib.assertTrue(not fs.exists(fs.combine(disk, '.settings'))); testlib.assertTrue(not result.settingsCopied); testlib.assertTrue(result.settingsWarning); - testlib.assertEquals(readAll(fs.combine(disk, 'startup.lua')), installer.buildAutorun()); + testlib.assertEquals(readAll(fs.combine(disk, 'startup.lua')), installer.buildAutorun('install')); +end); + +testlib.test('create reinstall disk skips settings clone', function() + local disk = freshDir(); + local settingsPath = fs.combine(freshDir(), '.settings'); + local drive = { name = 'top', mountPath = disk }; + local settingsApi = fakeSettings(); + writeFile(settingsPath, 'sandbox.url=https://example.test'); + + local installer = createInstallerDisk({ + peripheral = fakePeripheral({ drive }), + settings = settingsApi, + settingsSourcePath = settingsPath, + }); + local ok, result = installer.create({ mode = 'reinstall' }); + + testlib.assertTrue(ok, tostring(result)); + testlib.assertEquals(drive.label, 'TrapOS Disk'); + testlib.assertEquals(result.mode, 'reinstall'); + testlib.assertEquals(settingsApi.saveCount, 0); + testlib.assertTrue(not fs.exists(fs.combine(disk, '.settings'))); + testlib.assertTrue(not result.settingsCopied); + testlib.assertTrue(not result.settingsWarning); + testlib.assertEquals(readAll(fs.combine(disk, 'startup.lua')), installer.buildAutorun('reinstall')); end); testlib.run(); diff --git a/tests/install-trapos.lua b/tests/install-trapos.lua index c83fee5..c05ea5a 100644 --- a/tests/install-trapos.lua +++ b/tests/install-trapos.lua @@ -157,6 +157,11 @@ testlib.test('fresh bootstrap seeds a gitea master registry and runs ccpm', func f.close(); testlib.assertEquals(config.registries[1].branch, 'master'); testlib.assertEquals(config.registries[1].type, 'gitea'); + + f = fs.open(root .. '/trapos/ccpm.lock.json', 'r'); + local lock = textutils.unserializeJSON(f.readAll()); + f.close(); + testlib.assertEquals(lock.packages['trapos-core'].explicit, false); end); testlib.test('default install runs update, install trapos and postinstall', function() @@ -189,6 +194,11 @@ testlib.test('--cpm-only stops after the ccpm bootstrap', function() testlib.assertTrue(not findExecute(calls, 'ccpm', 'install')); testlib.assertTrue(not findExecute(calls, 'trapos-postinstall')); testlib.assertEquals(calls.rebooted, false); + + local f = fs.open(root .. '/trapos/ccpm.lock.json', 'r'); + local lock = textutils.unserializeJSON(f.readAll()); + f.close(); + testlib.assertEquals(lock.packages['trapos-core'].explicit, true); end); testlib.run();