From 6ecb5a83d630edb4b00167a3d42c02633ce5d125 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Mon, 15 Jun 2026 20:11:51 +0200 Subject: [PATCH] feat(installer): create provisioning disk --- apis/libinstallerdisk.lua | 124 +++++++++++++++ packages/index.json | 2 +- packages/trapos-core/ccpm.json | 4 +- programs/trapos-create-installer-disk.lua | 54 +++++++ tests/installer-disk.lua | 184 ++++++++++++++++++++++ 5 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 apis/libinstallerdisk.lua create mode 100644 programs/trapos-create-installer-disk.lua create mode 100644 tests/installer-disk.lua diff --git a/apis/libinstallerdisk.lua b/apis/libinstallerdisk.lua new file mode 100644 index 0000000..4ffc8db --- /dev/null +++ b/apis/libinstallerdisk.lua @@ -0,0 +1,124 @@ +local DEFAULT_INSTALLER_LABEL = 'TrapOS Installer'; +local DEFAULT_INSTALL_URL = 'https://trapos.trapcloud.fr/install'; +local DEFAULT_SETTINGS_SOURCE_PATH = '/.settings'; + +local function writeFile(path, body) + local file = fs.open(path, 'w'); + if not file then return false; end + file.write(body); + file.close(); + return true; +end + +local function createInstallerDisk(opts) + opts = opts or {}; + 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 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' + .. ' 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' + .. ' 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' + .. ' end\n' + .. 'end\n' + .. '\n' + .. "print('TrapOS Installer: installing TrapOS...');\n" + .. "shell.run('wget', 'run', INSTALL_URL);\n"; + end + + function api.findTargetDrive() + local names = peripheralApi.getNames and peripheralApi.getNames() or {}; + local presentCount = 0; + local nonEmptyCount = 0; + local empty = {}; + + for _, name in ipairs(names) do + if peripheralApi.getType(name) == 'drive' then + local drive = peripheralApi.wrap(name); + if drive.isDiskPresent() and drive.hasData() then + presentCount = presentCount + 1; + local mountPath = drive.getMountPath(); + if mountPath and #fs.list(mountPath) == 0 then + empty[#empty + 1] = { name = name, mountPath = mountPath, drive = drive }; + else + nonEmptyCount = nonEmptyCount + 1; + end + end + end + end + + if #empty > 1 then + return false, 'multiple blank disks found, remove all but one'; + end + if #empty == 1 then + return true, empty[1]; + end + if presentCount > 0 or nonEmptyCount > 0 then + return false, 'disk not empty; insert a blank floppy'; + end + return false, 'insert a blank floppy'; + end + + function api.create() + local ok, target = api.findTargetDrive(); + if not ok then return false, target; end + + target.drive.setDiskLabel(installerLabel); + + local result = { + name = target.name, + mountPath = target.mountPath, + label = installerLabel, + settingsCopied = false, + settingsWarning = false, + }; + + if fs.exists(settingsSourcePath) then + settingsApi.save(); + local dest = fs.combine(target.mountPath, '.settings'); + fs.delete(dest); + fs.copy(settingsSourcePath, dest); + result.settingsCopied = true; + else + result.settingsWarning = true; + end + + local startupPath = fs.combine(target.mountPath, 'startup.lua'); + fs.delete(startupPath); + if not writeFile(startupPath, api.buildAutorun()) then + return false, 'failed to write ' .. startupPath; + end + + return true, result; + end + + return api; +end + +return createInstallerDisk; diff --git a/packages/index.json b/packages/index.json index fba18a6..b0d651b 100644 --- a/packages/index.json +++ b/packages/index.json @@ -1,6 +1,6 @@ { "packages": { - "trapos-core": "0.6.3", + "trapos-core": "0.7.0", "trapos-test": "0.2.1", "trapos-boot": "0.4.0", "trapos-net": "0.3.0", diff --git a/packages/trapos-core/ccpm.json b/packages/trapos-core/ccpm.json index 45cef32..df47155 100644 --- a/packages/trapos-core/ccpm.json +++ b/packages/trapos-core/ccpm.json @@ -1,13 +1,15 @@ { "name": "trapos-core", - "version": "0.6.3", + "version": "0.7.0", "description": "TrapOS base: package manager, event loop, upgrade and event tools", "dependencies": [], "files": [ "apis/eventloop.lua", "apis/libccpm.lua", + "apis/libinstallerdisk.lua", "apis/libversion.lua", "programs/ccpm.lua", + "programs/trapos-create-installer-disk.lua", "programs/trapos-upgrade.lua", "programs/trapos-postinstall.lua", "programs/events.lua" diff --git a/programs/trapos-create-installer-disk.lua b/programs/trapos-create-installer-disk.lua new file mode 100644 index 0000000..fdf1fe6 --- /dev/null +++ b/programs/trapos-create-installer-disk.lua @@ -0,0 +1,54 @@ +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/installer-disk.lua b/tests/installer-disk.lua new file mode 100644 index 0000000..9d1597b --- /dev/null +++ b/tests/installer-disk.lua @@ -0,0 +1,184 @@ +local createLibTest = require('/apis/libtest'); +local createInstallerDisk = require('/apis/libinstallerdisk'); + +local testlib = createLibTest({ ... }); + +local counter = 0; + +local function freshDir() + counter = counter + 1; + local path = '/installer-disk-test/case-' .. counter; + fs.delete(path); + fs.makeDir(path); + return path; +end + +local function readAll(path) + local file = fs.open(path, 'r'); + local body = file.readAll(); + file.close(); + return body; +end + +local function writeFile(path, body) + local file = fs.open(path, 'w'); + file.write(body); + file.close(); +end + +local function fakePeripheral(drives) + local names = {}; + local byName = {}; + for i, drive in ipairs(drives) do + drive.name = drive.name or ('drive_' .. i); + drive.present = drive.present ~= false; + drive.data = drive.data ~= false; + names[#names + 1] = drive.name; + byName[drive.name] = drive; + end + + return { + getNames = function() + return names; + end, + getType = function(name) + if byName[name] then return 'drive'; end + return nil; + end, + wrap = function(name) + local drive = byName[name]; + return { + isDiskPresent = function() + return drive.present; + end, + hasData = function() + return drive.data; + end, + getMountPath = function() + return drive.mountPath; + end, + setDiskLabel = function(label) + drive.label = label; + end, + getDiskLabel = function() + return drive.label; + end, + }; + end, + }; +end + +local function fakeSettings() + local api = { saveCount = 0 }; + function api.save() + api.saveCount = api.saveCount + 1; + end + return api; +end + +testlib.test('buildAutorun copies settings before wget and no reboot', function() + local installer = createInstallerDisk(); + local body = installer.buildAutorun(); + local sentinel = string.find(body, "fs.exists('/trapos')", 1, true); + local label = string.find(body, 'TrapOS Installer', 1, true); + local url = string.find(body, 'https://trapos.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); + + testlib.assertTrue(sentinel); + testlib.assertTrue(label); + testlib.assertTrue(url); + testlib.assertTrue(copy); + testlib.assertTrue(wget); + testlib.assertTrue(copy < wget); + testlib.assertTrue(not string.find(body, 'os.reboot', 1, true)); +end); + +testlib.test('findTargetDrive asks for blank disk with zero drives', function() + local installer = createInstallerDisk({ peripheral = fakePeripheral({}) }); + local ok, err = installer.findTargetDrive(); + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, 'insert a blank', 1, true)); +end); + +testlib.test('findTargetDrive rejects a non-empty floppy', function() + local disk = freshDir(); + writeFile(fs.combine(disk, 'existing'), 'x'); + local installer = createInstallerDisk({ + peripheral = fakePeripheral({ { mountPath = disk } }), + }); + local ok, err = installer.findTargetDrive(); + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, 'not empty', 1, true)); +end); + +testlib.test('findTargetDrive picks one empty over one non-empty', function() + local nonEmpty = freshDir(); + local empty = freshDir(); + writeFile(fs.combine(nonEmpty, 'existing'), 'x'); + local installer = createInstallerDisk({ + peripheral = fakePeripheral({ + { name = 'left', mountPath = nonEmpty }, + { name = 'right', mountPath = empty }, + }), + }); + local ok, result = installer.findTargetDrive(); + testlib.assertTrue(ok, tostring(result)); + testlib.assertEquals(result.name, 'right'); + testlib.assertEquals(result.mountPath, empty); +end); + +testlib.test('findTargetDrive rejects multiple empty disks', function() + local installer = createInstallerDisk({ + peripheral = fakePeripheral({ + { mountPath = freshDir() }, + { mountPath = freshDir() }, + }), + }); + local ok, err = installer.findTargetDrive(); + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, 'remove all but one', 1, true)); +end); + +testlib.test('create labels disk, copies settings, writes startup', 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(); + + testlib.assertTrue(ok, tostring(result)); + testlib.assertEquals(drive.label, 'TrapOS Installer'); + testlib.assertEquals(settingsApi.saveCount, 1); + testlib.assertEquals(readAll(fs.combine(disk, 'startup.lua')), installer.buildAutorun()); + 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() + local disk = freshDir(); + local settingsApi = fakeSettings(); + local installer = createInstallerDisk({ + peripheral = fakePeripheral({ { mountPath = disk } }), + settings = settingsApi, + settingsSourcePath = fs.combine(freshDir(), '.settings'), + }); + local ok, result = installer.create(); + + 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()); +end); + +testlib.run();