feat(installer): add TrapOS disk modes
This commit is contained in:
parent
96e06041d9
commit
cb6ceaf575
158
apis/libccpm.lua
158
apis/libccpm.lua
@ -5,7 +5,8 @@
|
||||
-- State lives under `opts.stateDir` (default `/trapos`):
|
||||
-- - ccpm.json -> { registries = { { name, type, branch }, ... } }
|
||||
-- - ccpm.lock.json -> { packages = { <name> = { version, registry, files,
|
||||
-- dependencies, autostart } } }
|
||||
-- dependencies, autostart,
|
||||
-- explicit } } }
|
||||
-- - ccpm.cache.json -> { packages = { <name> = { 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;
|
||||
|
||||
@ -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,30 +23,55 @@ 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';
|
||||
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'
|
||||
.. 'local mount = installerMount();\n'
|
||||
.. buildDiskMountFunction()
|
||||
.. 'local mount = diskMount();\n'
|
||||
.. 'if mount then\n'
|
||||
.. " local src = fs.combine(mount, '.settings');\n"
|
||||
.. ' if fs.exists(src) then\n'
|
||||
@ -48,8 +81,63 @@ local function createInstallerDisk(opts)
|
||||
.. ' end\n'
|
||||
.. 'end\n'
|
||||
.. '\n'
|
||||
.. "print('TrapOS Installer: installing TrapOS...');\n"
|
||||
.. "shell.run('wget', 'run', INSTALL_URL);\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'
|
||||
.. '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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TrapOS",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"branch": "master",
|
||||
"packages": [
|
||||
"trapos"
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trapos",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"description": "TrapOS full install meta-package",
|
||||
"dependencies": [
|
||||
"trapos-boot",
|
||||
|
||||
@ -9,7 +9,8 @@ local function printUsage()
|
||||
print();
|
||||
print('\t\tccpm install <package>');
|
||||
print('\t\tccpm reinstall <package>');
|
||||
print('\t\tccpm uninstall <package>');
|
||||
print('\t\tccpm uninstall <package> [--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
|
||||
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
|
||||
|
||||
|
||||
87
programs/trapos-create-disk.lua
Normal file
87
programs/trapos-create-disk.lua
Normal file
@ -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
|
||||
@ -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.');
|
||||
140
tests/ccpm.lua
140
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({
|
||||
|
||||
@ -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();
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user