The file /opt/xetup/scripts/12-windows-update.ps1 has been updated successfully.

<#
.SYNOPSIS
    Installs all available Windows Updates via PSWindowsUpdate module.

.DESCRIPTION
    Installs all currently available updates without rebooting.
    Exits with code 9 when updates were installed (reboot required for
    further rounds) or code 0 when the system is already fully up to date.

    The xetup.exe state machine handles the reboot cycle:
      - exit 9  ->  xetup saves state, sets autologon + X9-Resume task, reboots
      - on each subsequent logon X9-Resume launches xetup --resume
      - xetup re-runs this step until it exits 0 (no more updates)
      - then disables autologon, removes X9-Resume, shows the summary screen

.ITEMS
    nainstalovat-pswindowsupdate-modul: Installs NuGet provider and PSWindowsUpdate module from PSGallery.
    spustit-kolo-windows-update: One update pass without reboot. Exits 9 when updates were applied (more rounds needed). Exits 0 when system is fully up to date.
#>
param(
    [object]$Config,
    [string]$LogFile
)

$ErrorActionPreference = "Continue"

function Write-Log {
    param([string]$Message, [string]$Level = "INFO")
    $line = "[$(Get-Date -Format 'HH:mm:ss')] [$Level] $Message"
    $null = New-Item -ItemType Directory -Force -Path (Split-Path $LogFile -Parent) -ErrorAction SilentlyContinue
    Add-Content -Path $LogFile -Value $line -Encoding UTF8
    Write-Output $line
}

Write-Log "=== Step 12 - Windows Update ===" -Level STEP

# -----------------------------------------------------------------------
# 1. NuGet provider + PSWindowsUpdate module
# -----------------------------------------------------------------------
Write-Log "Setting up PSWindowsUpdate module..." -Level INFO
try {
    Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers | Out-Null
    $existing = Get-Module -ListAvailable -Name PSWindowsUpdate | Select-Object -First 1
    if ($existing) {
        Write-Log "  PSWindowsUpdate $($existing.Version) already installed" -Level INFO
    } else {
        Install-Module -Name PSWindowsUpdate -Force -Scope AllUsers -AllowClobber | Out-Null
        Write-Log "  PSWindowsUpdate installed" -Level OK
    }
    Import-Module PSWindowsUpdate -Force
} catch {
    Write-Log "  Module setup failed: $_" -Level ERROR
    exit 1
}

# -----------------------------------------------------------------------
# 2. Check and install available updates
# -----------------------------------------------------------------------
Write-Log "Checking for available Windows Updates..." -Level INFO
try {
    $available = Get-WindowsUpdate -AcceptAll -IgnoreReboot -ErrorAction Stop
} catch {
    Write-Log "  Failed to check for updates: $_" -Level ERROR
    exit 1
}

if (-not $available -or $available.Count -eq 0) {
    Write-Log "  System is fully up to date" -Level OK
    Write-Log "Step 12 complete" -Level OK
    exit 0
}

Write-Log "  Found $($available.Count) update(s) - installing..." -Level INFO
try {
    $result = Install-WindowsUpdate -AcceptAll -IgnoreReboot -Verbose 2>&1
    $result | Where-Object { "$_" -match '\S' } | ForEach-Object { Write-Log "  $_" -Level INFO }
    Write-Log "  Update pass complete - reboot required for next round" -Level OK
} catch {
    Write-Log "  Update install failed: $_" -Level ERROR
    exit 1
}

# Signal xetup that a reboot is needed before running this step again
Write-Log "Step 12 - reboot required (exit 9)" -Level OK
exit 9

-    First pass: installs all currently available updates without rebooting.

-    Then registers a scheduled task "X9-WindowsUpdate" that runs on every

-    logon (as SYSTEM) until no more updates are found - handles the typical

-    2-3 reboot cycles required on a fresh Windows installation.

+    Installs all currently available updates without rebooting.

+    Exits with code 9 when updates were installed (reboot required for

+    further rounds) or code 0 when the system is already fully up to date.

-      3. On each subsequent logon the scheduled task runs another update pass

-      4. Task removes itself automatically when system is fully up to date

+    The xetup.exe state machine handles the reboot cycle:

+      - exit 9  ->  xetup saves state, sets autologon + X9-Resume task, reboots

+      - on each subsequent logon X9-Resume launches xetup --resume

+      - xetup re-runs this step until it exits 0 (no more updates)

+      - then disables autologon, removes X9-Resume, shows the summary screen

-    spustit-prvni-kolo-windows-update: First update pass without reboot - installs all currently available updates.

-    registrovat-scheduled-task-pro-dalsi-kola: Registers X9-WindowsUpdate scheduled task that runs on logon, handles post-reboot update rounds, and self-deletes when no more updates are found.

+    spustit-kolo-windows-update: One update pass without reboot. Exits 9 when updates were applied (more rounds needed). Exits 0 when system is fully up to date.

+# 2. Check and install available updates

-Write-Log "Running first Windows Update pass..." -Level INFO

+Write-Log "Checking for available Windows Updates..." -Level INFO

-    $result = Install-WindowsUpdate -AcceptAll -IgnoreReboot -Verbose 2>&1

-    $installed = @($result | Where-Object { $_ -match 'KB\d+|Downloaded|Installed' })

-        $result | Where-Object { "$_" -match '\S' } | ForEach-Object { Write-Log "  $_" -Level INFO }

-        Write-Log "  First pass complete - reboot required for remaining rounds" -Level OK

-        Write-Log "  System already up to date" -Level OK

+    $available = Get-WindowsUpdate -AcceptAll -IgnoreReboot -ErrorAction Stop

-    Write-Log "  First pass failed: $_" -Level ERROR

+    Write-Log "  Failed to check for updates: $_" -Level ERROR

-# 3. Enable autologon for adminx9 (temporary - disabled when updates complete)

-Write-Log "Enabling temporary autologon for adminx9..." -Level INFO

-$winlogonPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"

-    Set-ItemProperty -Path $winlogonPath -Name "AutoAdminLogon"  -Value "1"  -Type String -Force

-    Set-ItemProperty -Path $winlogonPath -Name "DefaultUserName" -Value "adminx9" -Type String -Force

-    Set-ItemProperty -Path $winlogonPath -Name "DefaultPassword" -Value "" -Type String -Force

-    Set-ItemProperty -Path $winlogonPath -Name "DefaultDomainName" -Value "." -Type String -Force

-    # Safety cap: max 10 automatic logons in case the task fails to clean up

-    Set-ItemProperty -Path $winlogonPath -Name "AutoLogonCount"  -Value 10 -Type DWord -Force

-    Write-Log "  Autologon enabled (adminx9, max 10 rounds)" -Level OK

-    Write-Log "  Failed to enable autologon: $_" -Level WARN

-    Write-Log "  Windows Update rounds will require manual login after each reboot" -Level WARN

+if (-not $available -or $available.Count -eq 0) {

+    Write-Log "  System is fully up to date" -Level OK

+    Write-Log "Step 12 complete" -Level OK

-# 4. Scheduled task for post-reboot update rounds (self-deleting)

-Write-Log "Registering post-reboot update task..." -Level INFO

-# PowerShell block that runs on each logon until no more updates found.

-# When done: disables autologon and removes itself.

-Import-Module PSWindowsUpdate -Force -ErrorAction Stop

-$updates = Get-WindowsUpdate -AcceptAll -IgnoreReboot

-    Install-WindowsUpdate -AcceptAll -IgnoreReboot | Out-File "C:\Windows\Setup\Scripts\wu-pass-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" -Encoding UTF8

-    # No more updates - disable autologon and clean up

-    $wl = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"

-    Set-ItemProperty -Path $wl -Name "AutoAdminLogon" -Value "0" -Type String -Force

-    Remove-ItemProperty -Path $wl -Name "DefaultPassword"   -ErrorAction SilentlyContinue

-    Remove-ItemProperty -Path $wl -Name "AutoLogonCount"    -ErrorAction SilentlyContinue

-    # Leave a visible marker on the shared Desktop so the operator knows it's done

-    $ts      = Get-Date -Format "yyyy-MM-dd HH:mm"

-    $doneMsg = "Windows Update dokoncen: $ts`r`nStroj je plne aktualizovan a pripraven k predani klientovi."

-        "C:\Users\Public\Desktop\! WU HOTOVO $ts.txt",

-    # Lock the workstation - login screen = clear visual signal for the operator.

-    # Runs as adminx9 (interactive session) via a one-shot task, self-deletes after 5 min.

-    $lockAction    = New-ScheduledTaskAction -Execute "rundll32.exe" -Argument "user32.dll,LockWorkStation"

-    $lockTrigger   = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(5)

-    $lockPrincipal = New-ScheduledTaskPrincipal -UserId "adminx9" -LogonType Interactive -RunLevel Limited

-    $lockSettings  = New-ScheduledTaskSettingsSet -DeleteExpiredTaskAfter (New-TimeSpan -Minutes 5)

-    Register-ScheduledTask -TaskName "X9-WUDoneLock" -Action $lockAction -Trigger $lockTrigger `

-        -Principal $lockPrincipal -Settings $lockSettings -Force -ErrorAction SilentlyContinue | Out-Null

-    Unregister-ScheduledTask -TaskName "X9-WindowsUpdate" -Confirm:$false

+Write-Log "  Found $($available.Count) update(s) - installing..." -Level INFO

-    $action  = New-ScheduledTaskAction -Execute "powershell.exe" `

-                 -Argument "-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command `"$updateScript`""

-    $trigger = New-ScheduledTaskTrigger -AtLogOn

-    $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 2) `

-                  -MultipleInstances IgnoreNew

-    $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest

-    # Remove existing task first (idempotent)

-    Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue

-    Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `

-      -Settings $settings -Principal $principal -Force | Out-Null

-    Write-Log "  Task '$taskName' registered - runs on each logon until fully updated" -Level OK

+    $result = Install-WindowsUpdate -AcceptAll -IgnoreReboot -Verbose 2>&1

+    $result | Where-Object { "$_" -match '\S' } | ForEach-Object { Write-Log "  $_" -Level INFO }

+    Write-Log "  Update pass complete - reboot required for next round" -Level OK

-    Write-Log "  Failed to register scheduled task: $_" -Level WARN

-    Write-Log "  Manual Windows Update rounds will be needed after reboot" -Level WARN

+    Write-Log "  Update install failed: $_" -Level ERROR

-Write-Log "Step 12 - Windows Update complete" -Level OK

+# Signal xetup that a reboot is needed before running this step again

+Write-Log "Step 12 - reboot required (exit 9)" -Level OK

<#
.SYNOPSIS
    Installs all available Windows Updates via PSWindowsUpdate module.

.DESCRIPTION
    First pass: installs all currently available updates without rebooting.
    Then registers a scheduled task "X9-WindowsUpdate" that runs on every
    logon (as SYSTEM) until no more updates are found - handles the typical
    2-3 reboot cycles required on a fresh Windows installation.

    Operator workflow:
      1. xetup completes all steps
      2. Operator reboots manually
      3. On each subsequent logon the scheduled task runs another update pass
      4. Task removes itself automatically when system is fully up to date

.ITEMS
    nainstalovat-pswindowsupdate-modul: Installs NuGet provider and PSWindowsUpdate module from PSGallery.
    spustit-prvni-kolo-windows-update: First update pass without reboot - installs all currently available updates.
    registrovat-scheduled-task-pro-dalsi-kola: Registers X9-WindowsUpdate scheduled task that runs on logon, handles post-reboot update rounds, and self-deletes when no more updates are found.
#>
param(
    [object]$Config,
    [string]$LogFile
)

$ErrorActionPreference = "Continue"

function Write-Log {
    param([string]$Message, [string]$Level = "INFO")
    $line = "[$(Get-Date -Format 'HH:mm:ss')] [$Level] $Message"
    $null = New-Item -ItemType Directory -Force -Path (Split-Path $LogFile -Parent) -ErrorAction SilentlyContinue
    Add-Content -Path $LogFile -Value $line -Encoding UTF8
    Write-Output $line
}

Write-Log "=== Step 12 - Windows Update ===" -Level STEP

# -----------------------------------------------------------------------
# 1. NuGet provider + PSWindowsUpdate module
# -----------------------------------------------------------------------
Write-Log "Setting up PSWindowsUpdate module..." -Level INFO
try {
    Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers | Out-Null
    $existing = Get-Module -ListAvailable -Name PSWindowsUpdate | Select-Object -First 1
    if ($existing) {
        Write-Log "  PSWindowsUpdate $($existing.Version) already installed" -Level INFO
    } else {
        Install-Module -Name PSWindowsUpdate -Force -Scope AllUsers -AllowClobber | Out-Null
        Write-Log "  PSWindowsUpdate installed" -Level OK
    }
    Import-Module PSWindowsUpdate -Force
} catch {
    Write-Log "  Module setup failed: $_" -Level ERROR
    exit 1
}

# -----------------------------------------------------------------------
# 2. First update pass (no reboot)
# -----------------------------------------------------------------------
Write-Log "Running first Windows Update pass..." -Level INFO
try {
    $result = Install-WindowsUpdate -AcceptAll -IgnoreReboot -Verbose 2>&1
    $installed = @($result | Where-Object { $_ -match 'KB\d+|Downloaded|Installed' })
    if ($installed.Count -gt 0) {
        $result | Where-Object { "$_" -match '\S' } | ForEach-Object { Write-Log "  $_" -Level INFO }
        Write-Log "  First pass complete - reboot required for remaining rounds" -Level OK
    } else {
        Write-Log "  System already up to date" -Level OK
    }
} catch {
    Write-Log "  First pass failed: $_" -Level ERROR
}

# -----------------------------------------------------------------------
# 3. Enable autologon for adminx9 (temporary - disabled when updates complete)
# -----------------------------------------------------------------------
Write-Log "Enabling temporary autologon for adminx9..." -Level INFO

$winlogonPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
try {
    Set-ItemProperty -Path $winlogonPath -Name "AutoAdminLogon"  -Value "1"  -Type String -Force
    Set-ItemProperty -Path $winlogonPath -Name "DefaultUserName" -Value "adminx9" -Type String -Force
    Set-ItemProperty -Path $winlogonPath -Name "DefaultPassword" -Value "" -Type String -Force
    Set-ItemProperty -Path $winlogonPath -Name "DefaultDomainName" -Value "." -Type String -Force
    # Safety cap: max 10 automatic logons in case the task fails to clean up
    Set-ItemProperty -Path $winlogonPath -Name "AutoLogonCount"  -Value 10 -Type DWord -Force
    Write-Log "  Autologon enabled (adminx9, max 10 rounds)" -Level OK
} catch {
    Write-Log "  Failed to enable autologon: $_" -Level WARN
    Write-Log "  Windows Update rounds will require manual login after each reboot" -Level WARN
}

# -----------------------------------------------------------------------
# 4. Scheduled task for post-reboot update rounds (self-deleting)
# -----------------------------------------------------------------------
Write-Log "Registering post-reboot update task..." -Level INFO

$taskName = "X9-WindowsUpdate"

# PowerShell block that runs on each logon until no more updates found.
# When done: disables autologon and removes itself.
$updateScript = @'
Import-Module PSWindowsUpdate -Force -ErrorAction Stop
$updates = Get-WindowsUpdate -AcceptAll -IgnoreReboot
if ($updates) {
    Install-WindowsUpdate -AcceptAll -IgnoreReboot | Out-File "C:\Windows\Setup\Scripts\wu-pass-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" -Encoding UTF8
} else {
    # No more updates - disable autologon and clean up
    $wl = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
    Set-ItemProperty -Path $wl -Name "AutoAdminLogon" -Value "0" -Type String -Force
    Remove-ItemProperty -Path $wl -Name "DefaultPassword"   -ErrorAction SilentlyContinue
    Remove-ItemProperty -Path $wl -Name "AutoLogonCount"    -ErrorAction SilentlyContinue

    # Leave a visible marker on the shared Desktop so the operator knows it's done
    $ts      = Get-Date -Format "yyyy-MM-dd HH:mm"
    $doneMsg = "Windows Update dokoncen: $ts`r`nStroj je plne aktualizovan a pripraven k predani klientovi."
    [System.IO.File]::WriteAllText(
        "C:\Users\Public\Desktop\! WU HOTOVO $ts.txt",
        $doneMsg,
        [System.Text.Encoding]::UTF8
    )

    # Lock the workstation - login screen = clear visual signal for the operator.
    # Runs as adminx9 (interactive session) via a one-shot task, self-deletes after 5 min.
    $lockAction    = New-ScheduledTaskAction -Execute "rundll32.exe" -Argument "user32.dll,LockWorkStation"
    $lockTrigger   = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(5)
    $lockPrincipal = New-ScheduledTaskPrincipal -UserId "adminx9" -LogonType Interactive -RunLevel Limited
    $lockSettings  = New-ScheduledTaskSettingsSet -DeleteExpiredTaskAfter (New-TimeSpan -Minutes 5)
    Register-ScheduledTask -TaskName "X9-WUDoneLock" -Action $lockAction -Trigger $lockTrigger `
        -Principal $lockPrincipal -Settings $lockSettings -Force -ErrorAction SilentlyContinue | Out-Null

    Unregister-ScheduledTask -TaskName "X9-WindowsUpdate" -Confirm:$false
}
'@

try {
    $action  = New-ScheduledTaskAction -Execute "powershell.exe" `
                 -Argument "-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command `"$updateScript`""
    $trigger = New-ScheduledTaskTrigger -AtLogOn
    $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 2) `
                  -MultipleInstances IgnoreNew
    $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest

    # Remove existing task first (idempotent)
    Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue

    Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
      -Settings $settings -Principal $principal -Force | Out-Null

    Write-Log "  Task '$taskName' registered - runs on each logon until fully updated" -Level OK
} catch {
    Write-Log "  Failed to register scheduled task: $_" -Level WARN
    Write-Log "  Manual Windows Update rounds will be needed after reboot" -Level WARN
}

Write-Log "Step 12 - Windows Update complete" -Level OK


Now let's try to build to check for compilation errors:


Bash

GOOS=linux GOARCH=amd64 go build ./internal/state/... 2>&1

Build state package for Linux


(Bash completed with no output)


GOOS=linux GOARCH=amd64 go build ./internal/prereboot/... 2>&1

Build prereboot package for Linux


GOOS=linux GOARCH=amd64 go build ./internal/runner/... 2>&1

Build runner package for Linux


Error: Exit code 1
# git.xetup.x9.cz/x9/xetup/internal/runner
internal/runner/runner.go:267:6: no new variables on left side of :=


258				continue
259			}
260			r.onLog(LogLine{
261				StepID: step.ID,
262				Text:   line,
263				Level:  parseLevel(line),
264			})
265		}
266
267		err := cmd.Wait()
268		if err != nil {
269			var exitErr *exec.ExitError
270			if errors.As(err, &exitErr) && exitErr.ExitCode() == 9 {
271				return ErrNeedsReboot
272			}
273		}
274		return err
275	}
276
277	// skipPSNoiseLine returns true for PowerShell stderr noise that clutters the log:

			continue
		}
		r.onLog(LogLine{
			StepID: step.ID,
			Text:   line,
			Level:  parseLevel(line),
		})
	}

	err := cmd.Wait()
	if err != nil {
		var exitErr *exec.ExitError
		if errors.As(err, &exitErr) && exitErr.ExitCode() == 9 {
			return ErrNeedsReboot
		}
	}
	return err
}

// skipPSNoiseLine returns true for PowerShell stderr noise that clutters the log:


218	}
219
220	func (r *Runner) runScript(ctx context.Context, step Step, cfgArg string) error {
221		scriptPath := filepath.Join(r.cfg.ScriptsDir, step.ScriptName)
222
223		// Build argument list
224		args := []string{
225			"-NonInteractive",
226			"-ExecutionPolicy", "Bypass",
227			"-File", scriptPath,
228			"-LogFile", r.cfg.LogFile,
229		}
230
231		// Pass config object as JSON string (script reads it inline)
232		if cfgArg != "" {
233			args = append(args, "-Config", fmt.Sprintf("(Get-Content '%s' | ConvertFrom-Json)", cfgArg))
234		}
235
236		// ProfileType for step 04
237		if step.ID == "defaultProfile" && r.cfg.ProfileType != "" {
238			args = append(args, "-ProfileType", r.cfg.ProfileType)
239		}
240
241		cmd := exec.CommandContext(ctx, "powershell.exe", args...)
242		hideWindow(cmd) // prevent PS console window from appearing over the GUI
243
244		stdout, err := cmd.StdoutPipe()
245		if err != nil {
246			return err
247		}
248		cmd.Stderr = cmd.Stdout // merge stderr into stdout
249
250		if err := cmd.Start(); err != nil {
251			return err
252		}
253
254		scanner := bufio.NewScanner(stdout)
255		for scanner.Scan() {
256			line := scanner.Text()
257			if skipPSNoiseLine(line) {
258				continue
259			}
260			r.onLog(LogLine{
261				StepID: step.ID,
262				Text:   line,
263				Level:  parseLevel(line),
264			})
265		}
266
267		err := cmd.Wait()
268		if err != nil {
269			var exitErr *exec.ExitError
270			if errors.As(err, &exitErr) && exitErr.ExitCode() == 9 {
271				return ErrNeedsReboot
272			}
273		}
274		return err
275	}
276
277	// skipPSNoiseLine returns true for PowerShell stderr noise that clutters the log:

}

func (r *Runner) runScript(ctx context.Context, step Step, cfgArg string) error {
	scriptPath := filepath.Join(r.cfg.ScriptsDir, step.ScriptName)

	// Build argument list
	args := []string{
		"-NonInteractive",
		"-ExecutionPolicy", "Bypass",
		"-File", scriptPath,
		"-LogFile", r.cfg.LogFile,
	}

	// Pass config object as JSON string (script reads it inline)
	if cfgArg != "" {
		args = append(args, "-Config", fmt.Sprintf("(Get-Content '%s' | ConvertFrom-Json)", cfgArg))
	}

	// ProfileType for step 04
	if step.ID == "defaultProfile" && r.cfg.ProfileType != "" {
		args = append(args, "-ProfileType", r.cfg.ProfileType)
	}

	cmd := exec.CommandContext(ctx, "powershell.exe", args...)
	hideWindow(cmd) // prevent PS console window from appearing over the GUI

	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return err
	}
	cmd.Stderr = cmd.Stdout // merge stderr into stdout

	if err := cmd.Start(); err != nil {
		return err
	}

	scanner := bufio.NewScanner(stdout)
	for scanner.Scan() {
		line := scanner.Text()
		if skipPSNoiseLine(line) {
			continue
		}
		r.onLog(LogLine{
			StepID: step.ID,
			Text:   line,
			Level:  parseLevel(line),
		})
	}

	err := cmd.Wait()
	if err != nil {
		var exitErr *exec.ExitError
		if errors.As(err, &exitErr) && exitErr.ExitCode() == 9 {
			return ErrNeedsReboot
		}
	}
	return err
}

// skipPSNoiseLine returns true for PowerShell stderr noise that clutters the log:


`err` is declared on line 244 with `stdout, err := ...`, so line 267 needs `=` not `:=`:


Edit

	err := cmd.Wait()
	if err != nil {
		var exitErr *exec.ExitError
		if errors.As(err, &exitErr) && exitErr.ExitCode() == 9 {
			return ErrNeedsReboot
		}
	}
	return err
}

	waitErr := cmd.Wait()
	if waitErr != nil {
		var exitErr *exec.ExitError
		if errors.As(waitErr, &exitErr) && exitErr.ExitCode() == 9 {
			return ErrNeedsReboot
		}
	}
	return waitErr
}


The file /opt/xetup/internal/runner/runner.go has been updated successfully.

	err := cmd.Wait()
	if err != nil {
		var exitErr *exec.ExitError
		if errors.As(err, &exitErr) && exitErr.ExitCode() == 9 {
			return ErrNeedsReboot
		}
	}
	return err
}

	waitErr := cmd.Wait()
	if waitErr != nil {
		var exitErr *exec.ExitError
		if errors.As(waitErr, &exitErr) && exitErr.ExitCode() == 9 {
			return ErrNeedsReboot
		}
	}
	return waitErr
}

-    if errors.As(err, &exitErr) && exitErr.ExitCode() == 9 {

+    if errors.As(waitErr, &exitErr) && exitErr.ExitCode() == 9 {

// skipPSNoiseLine returns true for PowerShell stderr noise that clutters the log:


Bash

GOOS=linux GOARCH=amd64 go build ./internal/runner/... 2>&1 && GOOS=linux GOARCH=amd64 go build ./cmd/xetup/... 2>&1 && echo "OK"

Build all packages for Linux


Error: Exit code 1
package git.xetup.x9.cz/x9/xetup/cmd/xetup
	imports git.xetup.x9.cz/x9/xetup/internal/gui: build constraints exclude all Go files in /opt/xetup/internal/gui


GOOS=linux GOARCH=amd64 go build ./internal/runner/... ./internal/state/... ./internal/prereboot/... 2>&1 && echo "OK - linux packages"

Build non-GUI packages for Linux


OK - linux packages


CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 go build -ldflags="-s -w -H windowsgui" -o /tmp/xetup.exe ./cmd/xetup 2>&1 && echo "OK - windows build"

Cross-compile for Windows


OK - windows build


Clean build. Let me verify the key parts of the implementation look correct:


grep -n "NeedsReboot\|ErrNeedsReboot\|StepsByIDs\|StepByID" /opt/xetup/internal/runner/runner.go

Verify runner changes


18:// ErrNeedsReboot is returned by runScript when a PowerShell script exits with
21:var ErrNeedsReboot = errors.New("reboot required")
139:	NeedsReboot bool // true when the script exited with code 9
192:			if errors.Is(err, ErrNeedsReboot) {
202:		res := Result{Step: step, Status: status, Elapsed: elapsed, NeedsReboot: needsReboot}
271:			return ErrNeedsReboot
383:// StepsByIDs returns steps from AllSteps() whose IDs are in the given list,
385:func StepsByIDs(ids []string) []Step {
399:// StepByID returns the Step with the given ID, and true if found.
400:func StepByID(id string) (Step, bool) {


grep -n "resumePhase\|prereboot\|state\.\|pendingStepIDs\|toStateResults\|prepareReboot" /opt/xetup/internal/gui/gui.go | head -40

Verify gui.go key functions


30:	"git.xetup.x9.cz/x9/xetup/internal/prereboot"
38:	if st, err := state.Load(); err == nil {
39:		resumePhase(st, runCfg)
56:				prepareRebootAndRestart(res.cfg, res.steps, results, cfgPath, runCfg)
69:func resumePhase(st *state.State, runCfg runner.RunConfig) {
76:	newResults := append(st.Results, toStateResults(results)...) //nolint:gocritic
80:		pending := pendingStepIDs(steps, results)
81:		newSt := &state.State{
88:		if err := state.Save(newSt); err != nil {
96:	prereboot.Cleanup()
97:	_ = state.Delete()
462:// prepareRebootAndRestart saves state, sets up resume infrastructure, and reboots.
463:func prepareRebootAndRestart(
470:	pending := pendingStepIDs(allSteps, completedResults)
471:	st := &state.State{
476:		Results:      toStateResults(completedResults),
478:	if err := state.Save(st); err != nil {
482:	if err := prereboot.Prepare(); err != nil {
489:// pendingStepIDs returns IDs of steps that still need to run.
492:func pendingStepIDs(allSteps []runner.Step, completedResults []runner.Result) []string {
508:// toStateResults converts runner results to state.StepResult for persistence.
510:func toStateResults(results []runner.Result) []state.StepResult {
511:	var sr []state.StepResult
516:		sr = append(sr, state.StepResult{
535:func donePhase(currentResults []runner.Result, prevResults []state.StepResult) {


grep -n "state\.\|StepsByIDs\|cfg\." /opt/xetup/cmd/xetup/main.go

Verify main.go changes


47:	if st, err := state.Load(); err == nil {
71:		ProfileType: cfg.Deployment.ProfileType,


grep -n "exit 9\|exit 0\|exit 1" /opt/xetup/scripts/12-windows-update.ps1

Verify WU script exit codes


11:      - exit 9  ->  xetup saves state, sets autologon + X9-Resume task, reboots
53:    exit 1
64:    exit 1
70:    exit 0
80:    exit 1
84:Write-Log "Step 12 - reboot required (exit 9)" -Level OK
85:exit 9


grep -n "exit 9" /opt/xetup/scripts/11-dell-update.ps1

Verify Dell update exit 9


151:            Write-Log "Step 11 complete - signalling reboot needed (exit 9)" -Level OK
152:            exit 9


Everything builds cleanly. Now let me push to the repo:


Check git status


On branch main
Your branch is ahead of 'origin/main' by 4 commits.
  (use "git push" to publish your local commits)

Last command done (1 command done):
   pick 8e413ab Initial repo structure
Next commands to do (41 remaining commands):
   pick 8e413ab Initial repo structure
   squash fb74a82 Add CLAUDE.md, SPEC.md and bootstrap scripts
  (use "git rebase --edit-todo" to view and edit)
You are currently editing a commit while rebasing branch 'main' on 'e6b5b9d'.
  (use "git commit --amend" to amend the current commit)
  (use "git rebase --continue" once you are satisfied with your changes)

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   cmd/xetup/main.go
	modified:   internal/gui/gui.go
	modified:   internal/runner/runner.go
	modified:   scripts/11-dell-update.ps1
	modified:   scripts/12-windows-update.ps1

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	.claude/scheduled_tasks.lock
	internal/prereboot/
	internal/state/
	web/data/deploy.json

no changes added to commit (use "git add" and/or "git commit -a")


Check recent commits


9feb7ba feat: WU completion indicators - lock screen + desktop file
3fb6578 fix: create File Explorer.lnk explicitly + autologon for WU rounds
1c7678c docs: add antivirus/curl note under download link
b58441d fix: black BackInfo bg + wrong taskbar accent color
9027718 feat: auto-deploy web on git pull + deploy SHA in spec footer
be412e9 docs(spec): update spec page to reflect current reality
83424a0 fix: correct step order and script names for steps 09/10
0462881 fix: taskbar pins, Edge NTP, black bg, step progress strip
3991e7a fix: mount HKCR PSDrive before PDF association writes
7e6095d Fixes, Windows Update (step 12), auto-reboot, PS window hide


Abort the in-progress rebase


(Bash completed with no output)


1	<#
2	.SYNOPSIS
3	    Detects Dell hardware, installs Dell Command | Update, and applies all available updates.
4
5	.DESCRIPTION
6	    Checks Win32_ComputerSystem.Manufacturer - if not Dell, the step exits silently without
7	    error so the same deployment script works on any hardware. On Dell machines, installs
8	    Dell Command | Update (Universal) via winget and immediately runs /applyUpdates with
9	    -reboot=disable. This covers drivers, firmware, and BIOS. BIOS and firmware updates are
10	    staged at this point and finalize automatically during the restart that closes the
11	    deployment. The operator does not need to run a separate update pass after setup.
12
13	.ITEMS
14	    detekce-dell-hw-win32-computersystem: Reads Win32_ComputerSystem.Manufacturer. If the string does not contain "Dell", the entire step is skipped without error. The deployment continues normally on HP, Lenovo, or any other brand.
15	    instalace-dell-command-update-via-winget: Installs Dell.CommandUpdate.Universal silently via winget. This is the current DCU generation (v5+) that supports Latitude, OptiPlex, Precision, Vostro, and XPS on Win10 and Win11.
16	    spusteni-vsech-aktualizaci-drivery-firmware-bios: Runs dcu-cli.exe /applyUpdates -silent -reboot=disable. Covers driver, firmware, and BIOS categories in a single pass. The -reboot=disable flag prevents DCU from rebooting mid-deployment.
17	    bios-firmware-staging-reboot: BIOS and firmware updates are staged by DCU and finalize on the next system restart. The deployment already ends with a restart (step 09 - computer rename), so no extra reboot is needed.
18	#>
19	param(
20	    [object]$Config,
21	    [string]$LogFile
22	)
23
24	$ErrorActionPreference = "Continue"
25
26	function Write-Log {
27	    param([string]$Message, [string]$Level = "INFO")
28	    $line = "[$(Get-Date -Format 'HH:mm:ss')] [$Level] $Message"
29	    $null = New-Item -ItemType Directory -Force -Path (Split-Path $LogFile -Parent) -ErrorAction SilentlyContinue
30	    Add-Content -Path $LogFile -Value $line -Encoding UTF8
31	    switch ($Level) {
32	        "OK"    { Write-Host $line -ForegroundColor Green }
33	        "ERROR" { Write-Host $line -ForegroundColor Red }
34	        "WARN"  { Write-Host $line -ForegroundColor Yellow }
35	        "STEP"  { Write-Host $line -ForegroundColor Cyan }
36	        default { Write-Host $line }
37	    }
38	}
39
40	function Get-Feature {
41	    param([object]$Cfg, [string]$StepID, [string]$FeatureID, [bool]$Default = $true)
42	    try {
43	        if ($null -eq $Cfg) { return $Default }
44	        $stepFeatures = $Cfg.features.$StepID
45	        if ($null -eq $stepFeatures) { return $Default }
46	        $val = $stepFeatures.$FeatureID
47	        if ($null -eq $val) { return $Default }
48	        return [bool]$val
49	    } catch { return $Default }
50	}
51
52	# -----------------------------------------------------------------------
53	# Detect Dell hardware
54	# -----------------------------------------------------------------------
55	Write-Log "Checking hardware manufacturer" -Level INFO
56
57	try {
58	    $cs           = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
59	    $manufacturer = $cs.Manufacturer
60	    $model        = $cs.Model
61	    Write-Log "  Manufacturer: $manufacturer  Model: $model" -Level INFO
62	}
63	catch {
64	    Write-Log "  Failed to query Win32_ComputerSystem: $_" -Level ERROR
65	    return
66	}
67
68	if ($manufacturer -notmatch "Dell") {
69	    Write-Log "Not a Dell machine ($manufacturer) - step skipped" -Level WARN
70	    return
71	}
72
73	Write-Log "Dell confirmed: $model" -Level OK
74
75	# -----------------------------------------------------------------------
76	# Install Dell Command | Update via winget
77	# -----------------------------------------------------------------------
78	Write-Log "Installing Dell Command | Update (Universal)..." -Level STEP
79
80	$wingetArgs = @(
81	    "install",
82	    "--id",   "Dell.CommandUpdate.Universal",
83	    "--silent",
84	    "--accept-package-agreements",
85	    "--accept-source-agreements"
86	)
87
88	$wingetOutput = & winget @wingetArgs 2>&1
89	$wingetExit   = $LASTEXITCODE
90	$wingetOutput | ForEach-Object { Write-Log "  [winget] $_" -Level INFO }
91
92	if ($wingetExit -ne 0 -and $wingetExit -ne 1638) {   # 1638 = already installed
93	    Write-Log "  winget exit code $wingetExit - checking if DCU is already present" -Level WARN
94	}
95
96	# Locate dcu-cli.exe (path is the same for x64 and Universal edition)
97	$dcuCandidates = @(
98	    "C:\Program Files\Dell\CommandUpdate\dcu-cli.exe",
99	    "C:\Program Files (x86)\Dell\CommandUpdate\dcu-cli.exe"
100	)
101	$dcuCli = $dcuCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
102
103	if (-not $dcuCli) {
104	    Write-Log "  dcu-cli.exe not found - cannot run updates" -Level ERROR
105	    return
106	}
107
108	Write-Log "  dcu-cli.exe found: $dcuCli" -Level OK
109
110	# -----------------------------------------------------------------------
111	# Run updates - categories controlled by feature flags
112	# -reboot=disable  -> no mid-deployment reboot; BIOS/firmware staged for next restart
113	# -----------------------------------------------------------------------
114	$runDrivers = Get-Feature $Config "dellUpdate" "drivers"
115	$runBios    = Get-Feature $Config "dellUpdate" "bios"
116
117	if (-not $runDrivers -and -not $runBios) {
118	    Write-Log "Both drivers and bios features disabled - skipping update run" -Level INFO
119	} else {
120	    # Build update type list from enabled features
121	    $updateTypes = @()
122	    if ($runDrivers) {
123	        $updateTypes += "driver"
124	        $updateTypes += "firmware"
125	    }
126	    if ($runBios) {
127	        $updateTypes += "bios"
128	    }
129	    $updateTypeArg = $updateTypes -join ","
130
131	    Write-Log "Running Dell Command | Update (updateType=$updateTypeArg, no auto-reboot)..." -Level STEP
132	    Write-Log "  This may take several minutes depending on available updates" -Level INFO
133
134	    $dcuOutput = & $dcuCli /applyUpdates -silent -reboot=disable "-updateType=$updateTypeArg" 2>&1
135	    $exitCode  = $LASTEXITCODE
136	    $dcuOutput | ForEach-Object { Write-Log "  [DCU] $_" -Level INFO }
137
138	    Write-Log "  DCU exit code: $exitCode" -Level INFO
139
140	    # Dell Command | Update exit codes:
141	    #  0 = completed, no updates required or updates applied (no reboot needed)
142	    #  1 = updates applied, reboot required to finalize BIOS/firmware
143	    #  5 = no applicable updates found for this system
144	    #  others = error or partial failure
145	    switch ($exitCode) {
146	        0       { Write-Log "Dell Command | Update: complete (no reboot required)" -Level OK }
147	        1       { Write-Log "Dell Command | Update: updates staged - BIOS/firmware will finalize on restart" -Level OK }
148	        5       { Write-Log "Dell Command | Update: no applicable updates for this model" -Level OK }
149	        default { Write-Log "Dell Command | Update: exit code $exitCode - review DCU log in C:\ProgramData\Dell\UpdateService\Logs" -Level WARN }
150	    }
151	}
152
153	Write-Log "Step 11 complete" -Level OK


1	//go:build windows
2
3	// Package gui implements the Walk-based graphical interface for xetup.
4	//
5	// Three sequential phases, each with its own window:
6	//  1. Config form  – PC name, product key, profile, step checkboxes,
7	//                    load/save config buttons for per-client presets
8	//  2. Live run     – real-time log streamed from PowerShell scripts
9	//  3. Summary      – per-step OK / ERROR / SKIPPED with elapsed time
10	package gui
11
12	import (
13		"context"
14		"encoding/json"
15		"fmt"
16		"os"
17		"os/exec"
18		"sync"
19		"time"
20
21		"github.com/lxn/walk"
22		. "github.com/lxn/walk/declarative"
23
24		"git.xetup.x9.cz/x9/xetup/internal/config"
25		"git.xetup.x9.cz/x9/xetup/internal/runner"
26	)
27
28	// Run opens the xetup window and blocks until the user closes it.
29	func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string) {
30		for {
31			res := formPhase(cfg, runCfg, cfgPath)
32			switch res.action {
33			case "quit":
34				return
35			case "reload":
36				cfg = res.cfg
37				cfgPath = res.cfgPath
38			case "run":
39				runCfg.ProfileType = res.cfg.Deployment.ProfileType
40				results := runPhase(runCfg, res.steps)
41				donePhase(results)
42				return
43			}
44		}
45	}
46
47	// --------------------------------------------------------------------------
48	// Phase 1 – Config form
49	// --------------------------------------------------------------------------
50
51	type formResult struct {
52		action  string // "run", "reload", "quit"
53		cfg     config.Config
54		cfgPath string
55		steps   []runner.Step
56	}
57
58	func formPhase(cfg config.Config, runCfg runner.RunConfig, cfgPath string) formResult {
59		var mw *walk.MainWindow
60		result := formResult{action: "quit", cfgPath: cfgPath}
61
62		var (
63			pcNameLE     *walk.LineEdit
64			pcDescLE     *walk.LineEdit
65			productKeyLE *walk.LineEdit
66			profileCB    *walk.ComboBox
67		)
68
69		items := runner.AllSelectableItems()
70		checkPtrs := make([]*walk.CheckBox, len(items))
71		checkWidgets := make([]Widget, len(items))
72		for i, item := range items {
73			i := i // capture for closure
74			checkWidgets[i] = CheckBox{
75				AssignTo: &checkPtrs[i],
76				Text:     item.Label,
77				Checked:  itemEnabled(cfg, item),
78			}
79		}
80
81		collectCfg := func() config.Config {
82			out := cfg
83			out.Deployment.PCName = pcNameLE.Text()
84			out.Deployment.PCDescription = pcDescLE.Text()
85			out.Activation.ProductKey = productKeyLE.Text()
86			profiles := []string{"default", "admin", "user"}
87			if idx := profileCB.CurrentIndex(); idx >= 0 && idx < len(profiles) {
88				out.Deployment.ProfileType = profiles[idx]
89			}
90			selected := make(map[string]bool, len(items))
91			for i, item := range items {
92				selected[item.Key] = checkPtrs[i].Checked()
93			}
94			_, features := buildStepsAndFeatures(selected)
95			out.Features = features
96			return out
97		}
98
99		if err := (MainWindow{
100			AssignTo: &mw,
101			Title:    "xetup \u2014 Windows deployment",
102			Size:     Size{Width: 760, Height: 740},
103			Layout:   VBox{},
104			Children: []Widget{
105				// ── Form fields ──────────────────────────────────────────────────
106				Composite{
107					Layout: Grid{Columns: 2},
108					Children: []Widget{
109						Label{Text: "PC jmeno:"},
110						LineEdit{
111							AssignTo:  &pcNameLE,
112							Text:      cfg.Deployment.PCName,
113							CueBanner: "napr. NB-KLIENT-01  (prazdne = neprejmenovat)",
114						},
115						Label{Text: "Popis PC:"},
116						LineEdit{
117							AssignTo:  &pcDescLE,
118							Text:      cfg.Deployment.PCDescription,
119							CueBanner: "napr. PC recepce",
120						},
121						Label{Text: "Product Key:"},
122						LineEdit{
123							AssignTo:  &productKeyLE,
124							Text:      cfg.Activation.ProductKey,
125							CueBanner: "prazdne = OA3 / GVLK fallback",
126						},
127						Label{Text: "Profil:"},
128						ComboBox{
129							AssignTo:     &profileCB,
130							Model:        []string{"default", "admin", "user"},
131							CurrentIndex: profileIndex(cfg.Deployment.ProfileType),
132						},
133					},
134				},
135				HSeparator{},
136				// ── Step checkboxes ──────────────────────────────────────────────
137				Label{Text: "Kroky a nastaveni (odskrtnete co nechcete spustit):"},
138				ScrollView{
139					MinSize:  Size{Height: 400},
140					Layout:   VBox{MarginsZero: true},
141					Children: checkWidgets,
142				},
143				HSeparator{},
144				// ── Buttons ──────────────────────────────────────────────────────
145				Composite{
146					Layout: HBox{MarginsZero: true},
147					Children: []Widget{
148						PushButton{
149							Text: "Nacist config...",
150							OnClicked: func() {
151								dlg := new(walk.FileDialog)
152								dlg.Filter = "JSON soubory (*.json)|*.json|Vsechny soubory (*.*)|*.*"
153								dlg.Title = "Nacist konfiguraci"
154								ok, err := dlg.ShowOpen(mw)
155								if err != nil || !ok {
156									return
157								}
158								data, err := os.ReadFile(dlg.FilePath)
159								if err != nil {
160									walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
161									return
162								}
163								newCfg := config.DefaultConfig()
164								if err := json.Unmarshal(data, &newCfg); err != nil {
165									walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
166									return
167								}
168								result = formResult{
169									action:  "reload",
170									cfg:     newCfg,
171									cfgPath: dlg.FilePath,
172								}
173								mw.Close()
174							},
175						},
176						PushButton{
177							Text: "Ulozit config...",
178							OnClicked: func() {
179								dlg := new(walk.FileDialog)
180								dlg.Filter = "JSON soubory (*.json)|*.json|Vsechny soubory (*.*)|*.*"
181								dlg.Title = "Ulozit konfiguraci"
182								dlg.FilePath = "config.json"
183								ok, err := dlg.ShowSave(mw)
184								if err != nil || !ok {
185									return
186								}
187								data, err := json.MarshalIndent(collectCfg(), "", "  ")
188								if err != nil {
189									walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
190									return
191								}
192								if err := os.WriteFile(dlg.FilePath, data, 0644); err != nil {
193									walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
194								}
195							},
196						},
197						HSpacer{},
198						PushButton{
199							Text: "  SPUSTIT  ",
200							OnClicked: func() {
201								finalCfg := collectCfg()
202								selected := make(map[string]bool, len(items))
203								for i, item := range items {
204									selected[item.Key] = checkPtrs[i].Checked()
205								}
206								steps, features := buildStepsAndFeatures(selected)
207								finalCfg.Features = features
208								_ = config.Save(finalCfg, cfgPath)
209								result = formResult{
210									action:  "run",
211									cfg:     finalCfg,
212									cfgPath: cfgPath,
213									steps:   steps,
214								}
215								mw.Close()
216							},
217						},
218					},
219				},
220			},
221		}.Create()); err != nil {
222			walk.MsgBox(nil, "Chyba", fmt.Sprintf("Nelze vytvorit okno: %v", err), walk.MsgBoxIconError)
223			return result
224		}
225
226		mw.Run()
227		return result
228	}
229
230	// --------------------------------------------------------------------------
231	// Phase 2 – Live run view
232	// --------------------------------------------------------------------------
233
234	func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {
235		var (
236			mw        *walk.MainWindow
237			statusLbl *walk.Label
238			logTE     *walk.TextEdit
239			cancelFn  context.CancelFunc
240		)
241
242		if err := (MainWindow{
243			AssignTo: &mw,
244			Title:    "xetup \u2014 probiha instalace",
245			Size:     Size{Width: 760, Height: 740},
246			Layout:   VBox{},
247			Children: []Widget{
248				Label{AssignTo: &statusLbl, Text: "Spoustim..."},
249				HSeparator{},
250				TextEdit{
251					AssignTo: &logTE,
252					ReadOnly: true,
253					VScroll:  true,
254					Font:     Font{Family: "Consolas", PointSize: 9},
255					MinSize:  Size{Height: 590},
256				},
257				HSeparator{},
258				Composite{

... [264 lines truncated] ...


16		"log"
17		"os"
18		"path/filepath"
19
20		xetupembed "git.xetup.x9.cz/x9/xetup"
21		"git.xetup.x9.cz/x9/xetup/internal/config"
22		"git.xetup.x9.cz/x9/xetup/internal/gui"
23		"git.xetup.x9.cz/x9/xetup/internal/runner"
24	)
25
26	func main() {
27		// Load config (falls back to defaults when config.json is missing)
28		cfgPath := config.ConfigPath()
29		cfg, err := config.Load(cfgPath)
30		if err != nil {
31			fmt.Fprintf(os.Stderr, "Warning: config load failed (%v), using defaults\n", err)
32		}
33
34		// Temp working directory – cleaned up on exit
35		tmpDir, err := os.MkdirTemp("", "xetup-*")
36		if err != nil {
37			log.Fatalf("Cannot create temp dir: %v", err)
38		}
39		defer os.RemoveAll(tmpDir)
40
41		// Extract embedded scripts and assets
42		if err := runner.ExtractScripts(xetupembed.Scripts, tmpDir); err != nil {
43			log.Fatalf("Failed to extract scripts: %v", err)
44		}
45		if err := runner.ExtractAssets(xetupembed.Assets, tmpDir); err != nil {
46			log.Fatalf("Failed to extract assets: %v", err)
47		}
48
49		// Write runtime config JSON so PowerShell scripts can read it
50		cfgRuntimePath, err := runner.WriteConfig(cfg, tmpDir)
51		if err != nil {
52			log.Fatalf("Failed to write runtime config: %v", err)
53		}
54
55		runCfg := runner.RunConfig{
56			ScriptsDir:  filepath.Join(tmpDir, "scripts"),


1	<#
2	.SYNOPSIS
3	    Installs all available Windows Updates via PSWindowsUpdate module.
4
5	.DESCRIPTION
6	    First pass: installs all currently available updates without rebooting.
7	    Then registers a scheduled task "X9-WindowsUpdate" that runs on every
8	    logon (as SYSTEM) until no more updates are found - handles the typical
9	    2-3 reboot cycles required on a fresh Windows installation.
10
11	    Operator workflow:
12	      1. xetup completes all steps
13	      2. Operator reboots manually
14	      3. On each subsequent logon the scheduled task runs another update pass
15	      4. Task removes itself automatically when system is fully up to date
16
17	.ITEMS
18	    nainstalovat-pswindowsupdate-modul: Installs NuGet provider and PSWindowsUpdate module from PSGallery.
19	    spustit-prvni-kolo-windows-update: First update pass without reboot - installs all currently available updates.
20	    registrovat-scheduled-task-pro-dalsi-kola: Registers X9-WindowsUpdate scheduled task that runs on logon, handles post-reboot update rounds, and self-deletes when no more updates are found.
21	#>
22	param(
23	    [object]$Config,
24	    [string]$LogFile
25	)
26
27	$ErrorActionPreference = "Continue"
28
...
49	    }
50	    Import-Module PSWindowsUpdate -Force
51	} catch {
52	    Write-Log "  Module setup failed: $_" -Level ERROR
53	    exit 1
54	}
55
56	# -----------------------------------------------------------------------
57	# 2. First update pass (no reboot)
58	# -----------------------------------------------------------------------
59	Write-Log "Running first Windows Update pass..." -Level INFO
60	try {
61	    $result = Install-WindowsUpdate -AcceptAll -IgnoreReboot -Verbose 2>&1
62	    $installed = @($result | Where-Object { $_ -match 'KB\d+|Downloaded|Installed' })
63	    if ($installed.Count -gt 0) {
64	        $result | Where-Object { "$_" -match '\S' } | ForEach-Object { Write-Log "  $_" -Level INFO }
65	        Write-Log "  First pass complete - reboot required for remaining rounds" -Level OK
66	    } else {
67	        Write-Log "  System already up to date" -Level OK
68	    }
69	} catch {
70	    Write-Log "  First pass failed: $_" -Level ERROR
71	}
72
73	# -----------------------------------------------------------------------
74	# 3. Scheduled task for post-reboot update rounds (self-deleting)
75	# -----------------------------------------------------------------------
76	Write-Log "Registering post-reboot update task..." -Level INFO
77
78	$taskName = "X9-WindowsUpdate"
79
80	# PowerShell block that runs on each logon until no more updates found
81	$updateScript = @'
82	Import-Module PSWindowsUpdate -Force -ErrorAction Stop
83	$updates = Get-WindowsUpdate -AcceptAll -IgnoreReboot
84	if ($updates) {
85	    Install-WindowsUpdate -AcceptAll -IgnoreReboot | Out-File "C:\Windows\Setup\Scripts\wu-pass-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" -Encoding UTF8
86	} else {
87	    # No more updates - remove this task
88	    Unregister-ScheduledTask -TaskName "X9-WindowsUpdate" -Confirm:$false
89	}
90	'@
91
92	try {
93	    $action  = New-ScheduledTaskAction -Execute "powershell.exe" `
94	                 -Argument "-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command `"$updateScript`""
95	    $trigger = New-ScheduledTaskTrigger -AtLogOn
96	    $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 2) `
97	                  -MultipleInstances IgnoreNew
98	    $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest
99
100	    # Remove existing task first (idempotent)
101	    Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
102
103	    Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
104	      -Settings $settings -Principal $principal -Force | Out-Null
105
106	    Write-Log "  Task '$taskName' registered - runs on each logon until fully updated" -Level OK
107	} catch {
108	    Write-Log "  Failed to register scheduled task: $_" -Level WARN
109	    Write-Log "  Manual Windows Update rounds will be needed after reboot" -Level WARN
110	}
111
112	Write-Log "Step 12 - Windows Update complete" -Level OK
113	Write-Log "  ACTION REQUIRED: Reboot the machine to complete remaining update rounds" -Level WARN


1	// Package runner executes PowerShell deployment scripts and streams log output.
2	package runner
3
4	import (
5		"bufio"
6		"context"
7		"encoding/json"
8		"fmt"
9		"os"
10		"os/exec"
11		"path"
12		"path/filepath"
13		"strings"
14		"time"
15	)
16
17	// Step describes a single deployment step.
18	type Step struct {
19		ID         string // e.g. "adminAccount"
20		Num        string // display number e.g. "00"
21		Name       string
22		ScriptName string // e.g. "00-admin-account.ps1"
23		Enabled    bool
24	}
25
26	// AllSteps returns the ordered list of deployment steps.
27	func AllSteps() []Step {
28		return []Step{
29			{ID: "adminAccount",    Num: "00", Name: "Admin ucet",              ScriptName: "00-admin-account.ps1"},
30			{ID: "bloatware",       Num: "01", Name: "Bloatware removal",        ScriptName: "01-bloatware.ps1"},
31			{ID: "software",        Num: "02", Name: "Software (winget)",         ScriptName: "02-software.ps1"},
32			{ID: "systemRegistry",  Num: "03", Name: "System Registry (HKLM)",   ScriptName: "03-system-registry.ps1"},
33			{ID: "defaultProfile",  Num: "04", Name: "Default Profile",          ScriptName: "04-default-profile.ps1"},
34			{ID: "personalization", Num: "05", Name: "Personalizace",             ScriptName: "05-personalization.ps1"},
35			{ID: "scheduledTasks",  Num: "06", Name: "Scheduled Tasks",           ScriptName: "06-scheduled-tasks.ps1"},
36			{ID: "backinfo",        Num: "07", Name: "BackInfo",                  ScriptName: "07-backinfo.ps1"},
37			{ID: "activation",      Num: "08", Name: "Windows aktivace",          ScriptName: "08-activation.ps1"},
38			{ID: "dellUpdate",      Num: "11", Name: "Dell Command | Update",      ScriptName: "11-dell-update.ps1"},
39			{ID: "windowsUpdate",   Num: "12", Name: "Windows Update",            ScriptName: "12-windows-update.ps1"},
40			{ID: "network",         Num: "09", Name: "Network discovery",         ScriptName: "10-network.ps1"},
41			{ID: "pcIdentity",      Num: "10", Name: "PC identita",               ScriptName: "09-pc-identity.ps1"},
42		}
43	}
44
45	// Feature is a single toggleable sub-item within a deployment step.
46	type Feature struct {
47		ID    string
48		Label string
49	}
50
51	// StepFeatures returns per-step feature lists. Steps absent from this map
52	// have no sub-features and are controlled at the step level only.
53	func StepFeatures() map[string][]Feature {
54		return map[string][]Feature{
55			"software": {
56				{ID: "wingetInstalls", Label: "Instalace SW ze seznamu (winget)"},
57				{ID: "pdfDefault",     Label: "Adobe Reader jako vychozi PDF"},
58				{ID: "ateraAgent",     Label: "Atera RMM agent"},
59			},
60			"systemRegistry": {
61				{ID: "systemTweaks",      Label: "Windows tweaky (Widgets, GameDVR, Recall...)"},
62				{ID: "edgePolicies",      Label: "Edge policies (tlacitka, vyhledavac, telemetrie)"},
63				{ID: "oneDriveUninstall", Label: "OneDrive uninstall (consumer pre-install)"},
64				{ID: "powercfg",          Label: "Nastaveni napajeni (timeout AC/DC)"},
65				{ID: "proxyDisable",      Label: "Zakaz WPAD proxy auto-detect"},
66			},
67			"defaultProfile": {
68				{ID: "taskbarTweaks",   Label: "Taskbar – zarovnani, tlacitka, layout XML"},
69				{ID: "startMenuTweaks", Label: "Start menu – cisteni pinu, Bing, Copilot"},
70				{ID: "explorerTweaks",  Label: "Explorer – pripony, LaunchTo, ShowRecent"},
71			},
72			"dellUpdate": {
73				{ID: "drivers", Label: "Dell drivery + firmware"},
74				{ID: "bios",    Label: "Dell BIOS update"},
75			},
76		}
77	}
78
79	// SelectableItem is a single toggleable row in the TUI checklist.
80	// It represents either a whole step (FeatureID == "") or a specific feature.
81	type SelectableItem struct {
82		Key       string // "stepID" or "stepID.featureID"
83		StepID    string
84		FeatureID string // empty for step-level items
85		Label     string
86		Num       string
87	}
88
89	// AllSelectableItems returns the flat ordered list of all TUI toggle rows.
90	// Steps with features are expanded to individual feature rows.
91	// Steps without features appear as a single step-level row.
92	func AllSelectableItems() []SelectableItem {
93		steps    := AllSteps()
94		features := StepFeatures()
95		var items []SelectableItem
96		for _, s := range steps {
97			feats, hasFeatures := features[s.ID]
98			if !hasFeatures {
99				items = append(items, SelectableItem{
100					Key:    s.ID,
101					StepID: s.ID,
102					Label:  s.Num + " – " + s.Name,
103					Num:    s.Num,
104				})
105			} else {
106				for _, f := range feats {
107					items = append(items, SelectableItem{
108						Key:       s.ID + "." + f.ID,
109						StepID:    s.ID,
110						FeatureID: f.ID,
111						Label:     s.Num + " – " + f.Label,
112						Num:       s.Num,
113					})
114				}
115			}
116		}
117		return items
118	}
119
120	// RunConfig holds runtime parameters passed to each script.
121	type RunConfig struct {
122		ScriptsDir  string
123		ConfigPath  string
124		LogFile     string
125		ProfileType string
126	}
127
128	// Result is the outcome of a single step.
129	type Result struct {
130		Step    Step
131		Status  string // "OK", "ERROR", "SKIPPED"
132		Elapsed time.Duration
133	}
134
135	// LogLine is a single output line from a running script.
136	type LogLine struct {
137		StepID string
138		Text   string
139		Level  string // INFO, OK, ERROR, WARN, STEP - parsed from [LEVEL] prefix
140	}
141
142	// Runner executes deployment steps sequentially.
143	type Runner struct {
144		cfg      RunConfig
145		onLog    func(LogLine)
146		onResult func(Result)
147		cancel   context.CancelFunc
148	}
149
150	// New creates a Runner. onLog is called for each output line, onResult after each step.
151	func New(cfg RunConfig, onLog func(LogLine), onResult func(Result)) *Runner {
152		return &Runner{cfg: cfg, onLog: onLog, onResult: onResult}
153	}
154
155	// Run executes enabled steps sequentially. Blocks until done or context cancelled.
156	func (r *Runner) Run(ctx context.Context, steps []Step) []Result {
157		ctx, cancel := context.WithCancel(ctx)
158		r.cancel = cancel
159		defer cancel()
160
161		// Write config JSON to temp file so scripts can read it
162		cfgArg := r.cfg.ConfigPath
163
164		var results []Result
165		for _, step := range steps {
166			if !step.Enabled {
167				res := Result{Step: step, Status: "SKIPPED"}
168				r.onResult(res)
169				results = append(results, res)
170				continue
171			}
172
173			start := time.Now()
174			err := r.runScript(ctx, step, cfgArg)
175			elapsed := time.Since(start)
176
177			status := "OK"
178			if err != nil {
179				if ctx.Err() != nil {
180					status = "CANCELLED"
181				} else {
182					status = "ERROR"
183				}
184			}
185
186			res := Result{Step: step, Status: status, Elapsed: elapsed}
187			r.onResult(res)
188			results = append(results, res)
189
190			if ctx.Err() != nil {
191				break
192			}
193		}
194		return results
195	}
196
197	// Stop cancels the running deployment.
198	func (r *Runner) Stop() {
199		if r.cancel != nil {
200			r.cancel()
201		}
202	}
203
204	func (r *Runner) runScript(ctx context.Context, step Step, cfgArg string) error {
205		scriptPath := filepath.Join(r.cfg.ScriptsDir, step.ScriptName)
206
207		// Build argument list
208		args := []string{
209			"-NonInteractive",
210			"-ExecutionPolicy", "Bypass",
211			"-File", scriptPath,
212			"-LogFile", r.cfg.LogFile,
213		}
214
215		// Pass config object as JSON string (script reads it inline)
216		if cfgArg != "" {
217			args = append(args, "-Config", fmt.Sprintf("(Get-Content '%s' | ConvertFrom-Json)", cfgArg))
218		}
219
220		// ProfileType for step 04
221		if step.ID == "defaultProfile" && r.cfg.ProfileType != "" {
222			args = append(args, "-ProfileType", r.cfg.ProfileType)
223		}
224
225		cmd := exec.CommandContext(ctx, "powershell.exe", args...)
226		hideWindow(cmd) // prevent PS console window from appearing over the GUI
227
228		stdout, err := cmd.StdoutPipe()
229		if err != nil {
230			return err
231		}
232		cmd.Stderr = cmd.Stdout // merge stderr into stdout
233
234		if err := cmd.Start(); err != nil {
235			return err
236		}
237

... [132 lines truncated] ...


Check git status after rebase abort


On branch main
Your branch and 'origin/main' have diverged,
and have 41 and 8 different commits each, respectively.
  (use "git pull" to merge the remote branch into yours)

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	.claude/scheduled_tasks.lock
	internal/prereboot/
	internal/state/
	web/data/deploy.json

nothing added to commit but untracked files present (use "git add" to track)


head -20 /opt/xetup/internal/runner/runner.go && echo "---" && head -5 /opt/xetup/internal/gui/gui.go

Check if files were reverted by rebase abort


// Package runner executes PowerShell deployment scripts and streams log output.
package runner

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
	"time"
)

// Step describes a single deployment step.
type Step struct {
	ID         string // e.g. "adminAccount"
	Num        string // display number e.g. "00"
---
//go:build windows

// Package gui implements the Walk-based graphical interface for xetup.
//
// Three sequential phases, each with its own window:


grep -n "NeedsReboot\|ErrNeedsReboot" /opt/xetup/internal/runner/runner.go | head -5

Check if runner changes survived


grep -n "prereboot\|resumePhase\|state\." /opt/xetup/internal/gui/gui.go | head -5

Check if gui changes survived


The rebase abort reverted tracked file changes. I need to re-apply them. Let me do all at once:


1	// Package runner executes PowerShell deployment scripts and streams log output.
2	package runner
3
4	import (
5		"bufio"
6		"context"
7		"encoding/json"
8		"fmt"
9		"os"
10		"os/exec"
11		"path"
12		"path/filepath"
13		"strings"
14		"time"
15	)
16
17	// Step describes a single deployment step.
18	type Step struct {
19		ID         string // e.g. "adminAccount"
20		Num        string // display number e.g. "00"

// Package runner executes PowerShell deployment scripts and streams log output.
package runner

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
	"time"
)

// Step describes a single deployment step.
type Step struct {
	ID         string // e.g. "adminAccount"
	Num        string // display number e.g. "00"


Edit

// Package runner executes PowerShell deployment scripts and streams log output.
package runner

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
	"time"
)

// Package runner executes PowerShell deployment scripts and streams log output.
package runner

import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
	"time"
)

// ErrNeedsReboot is returned by runScript when a PowerShell script exits with
// code 9, signalling that it completed successfully but requires a system
// reboot before deployment can continue.
var ErrNeedsReboot = errors.New("reboot required")


// Package runner executes PowerShell deployment scripts and streams log output.
package runner

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
	"time"
)

// Package runner executes PowerShell deployment scripts and streams log output.
package runner

import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
	"time"
)

// ErrNeedsReboot is returned by runScript when a PowerShell script exits with
// code 9, signalling that it completed successfully but requires a system
// reboot before deployment can continue.
var ErrNeedsReboot = errors.New("reboot required")

"bufio"
"context"
"encoding/json"
+  "errors"
"fmt"
"os"
"os/exec"

+// ErrNeedsReboot is returned by runScript when a PowerShell script exits with

+// code 9, signalling that it completed successfully but requires a system

+// reboot before deployment can continue.

+var ErrNeedsReboot = errors.New("reboot required")

// Step describes a single deployment step.

ID         string // e.g. "adminAccount"


125
126	// RunConfig holds runtime parameters passed to each script.
127	type RunConfig struct {
128		ScriptsDir  string
129		ConfigPath  string
130		LogFile     string
131		ProfileType string
132	}
133
134	// Result is the outcome of a single step.
135	type Result struct {
136		Step    Step
137		Status  string // "OK", "ERROR", "SKIPPED"
138		Elapsed time.Duration
139	}
140
141	// LogLine is a single output line from a running script.
142	type LogLine struct {
143		StepID string
144		Text   string

// RunConfig holds runtime parameters passed to each script.
type RunConfig struct {
	ScriptsDir  string
	ConfigPath  string
	LogFile     string
	ProfileType string
}

// Result is the outcome of a single step.
type Result struct {
	Step    Step
	Status  string // "OK", "ERROR", "SKIPPED"
	Elapsed time.Duration
}

// LogLine is a single output line from a running script.
type LogLine struct {
	StepID string
	Text   string


// Result is the outcome of a single step.
type Result struct {
	Step    Step
	Status  string // "OK", "ERROR", "SKIPPED"
	Elapsed time.Duration
}

// Result is the outcome of a single step.
type Result struct {
	Step        Step
	Status      string // "OK", "ERROR", "SKIPPED", "CANCELLED"
	Elapsed     time.Duration
	NeedsReboot bool // true when the script exited with code 9
}


// Result is the outcome of a single step.
type Result struct {
	Step    Step
	Status  string // "OK", "ERROR", "SKIPPED"
	Elapsed time.Duration
}

// Result is the outcome of a single step.
type Result struct {
	Step        Step
	Status      string // "OK", "ERROR", "SKIPPED", "CANCELLED"
	Elapsed     time.Duration
	NeedsReboot bool // true when the script exited with code 9
}

-  Status  string // "OK", "ERROR", "SKIPPED"

+  Status      string // "OK", "ERROR", "SKIPPED", "CANCELLED"

+  NeedsReboot bool // true when the script exited with code 9

// LogLine is a single output line from a running script.


155	}
156
157	// New creates a Runner. onLog is called for each output line, onResult after each step.
158	func New(cfg RunConfig, onLog func(LogLine), onResult func(Result)) *Runner {
159		return &Runner{cfg: cfg, onLog: onLog, onResult: onResult}
160	}
161
162	// Run executes enabled steps sequentially. Blocks until done or context cancelled.
163	func (r *Runner) Run(ctx context.Context, steps []Step) []Result {
164		ctx, cancel := context.WithCancel(ctx)
165		r.cancel = cancel
166		defer cancel()
167
168		// Write config JSON to temp file so scripts can read it
169		cfgArg := r.cfg.ConfigPath
170
171		var results []Result
172		for _, step := range steps {
173			if !step.Enabled {
174				res := Result{Step: step, Status: "SKIPPED"}
175				r.onResult(res)
176				results = append(results, res)
177				continue
178			}
179
180			start := time.Now()
181			err := r.runScript(ctx, step, cfgArg)
182			elapsed := time.Since(start)
183
184			status := "OK"
185			if err != nil {
186				if ctx.Err() != nil {
187					status = "CANCELLED"
188				} else {
189					status = "ERROR"
190				}
191			}
192
193			res := Result{Step: step, Status: status, Elapsed: elapsed}
194			r.onResult(res)
195			results = append(results, res)
196
197			if ctx.Err() != nil {
198				break
199			}
200		}
201		return results
202	}
203
204	// Stop cancels the running deployment.
205	func (r *Runner) Stop() {
206		if r.cancel != nil {
207			r.cancel()
208		}
209	}
210
211	func (r *Runner) runScript(ctx context.Context, step Step, cfgArg string) error {
212		scriptPath := filepath.Join(r.cfg.ScriptsDir, step.ScriptName)
213
214		// Build argument list

}

// New creates a Runner. onLog is called for each output line, onResult after each step.
func New(cfg RunConfig, onLog func(LogLine), onResult func(Result)) *Runner {
	return &Runner{cfg: cfg, onLog: onLog, onResult: onResult}
}

// Run executes enabled steps sequentially. Blocks until done or context cancelled.
func (r *Runner) Run(ctx context.Context, steps []Step) []Result {
	ctx, cancel := context.WithCancel(ctx)
	r.cancel = cancel
	defer cancel()

	// Write config JSON to temp file so scripts can read it
	cfgArg := r.cfg.ConfigPath

	var results []Result
	for _, step := range steps {
		if !step.Enabled {
			res := Result{Step: step, Status: "SKIPPED"}
			r.onResult(res)
			results = append(results, res)
			continue
		}

		start := time.Now()
		err := r.runScript(ctx, step, cfgArg)
		elapsed := time.Since(start)

		status := "OK"
		if err != nil {
			if ctx.Err() != nil {
				status = "CANCELLED"
			} else {
				status = "ERROR"
			}
		}

		res := Result{Step: step, Status: status, Elapsed: elapsed}
		r.onResult(res)
		results = append(results, res)

		if ctx.Err() != nil {
			break
		}
	}
	return results
}

// Stop cancels the running deployment.
func (r *Runner) Stop() {
	if r.cancel != nil {
		r.cancel()
	}
}

func (r *Runner) runScript(ctx context.Context, step Step, cfgArg string) error {
	scriptPath := filepath.Join(r.cfg.ScriptsDir, step.ScriptName)

	// Build argument list


		start := time.Now()
		err := r.runScript(ctx, step, cfgArg)
		elapsed := time.Since(start)

		status := "OK"
		if err != nil {
			if ctx.Err() != nil {
				status = "CANCELLED"
			} else {
				status = "ERROR"
			}
		}

		res := Result{Step: step, Status: status, Elapsed: elapsed}
		r.onResult(res)
		results = append(results, res)

		if ctx.Err() != nil {
			break
		}

		start := time.Now()
		err := r.runScript(ctx, step, cfgArg)
		elapsed := time.Since(start)

		status := "OK"
		needsReboot := false
		if err != nil {
			if errors.Is(err, ErrNeedsReboot) {
				needsReboot = true
				// status remains "OK": step completed, reboot is just required to continue
			} else if ctx.Err() != nil {
				status = "CANCELLED"
			} else {
				status = "ERROR"
			}
		}

		res := Result{Step: step, Status: status, Elapsed: elapsed, NeedsReboot: needsReboot}
		r.onResult(res)
		results = append(results, res)

		if ctx.Err() != nil || needsReboot {
			break
		}


The file /opt/xetup/internal/runner/runner.go has been updated successfully.

		start := time.Now()
		err := r.runScript(ctx, step, cfgArg)
		elapsed := time.Since(start)

		status := "OK"
		if err != nil {
			if ctx.Err() != nil {
				status = "CANCELLED"
			} else {
				status = "ERROR"
			}
		}

		res := Result{Step: step, Status: status, Elapsed: elapsed}
		r.onResult(res)
		results = append(results, res)

		if ctx.Err() != nil {
			break
		}

		start := time.Now()
		err := r.runScript(ctx, step, cfgArg)
		elapsed := time.Since(start)

		status := "OK"
		needsReboot := false
		if err != nil {
			if errors.Is(err, ErrNeedsReboot) {
				needsReboot = true
				// status remains "OK": step completed, reboot is just required to continue
			} else if ctx.Err() != nil {
				status = "CANCELLED"
			} else {
				status = "ERROR"
			}
		}

		res := Result{Step: step, Status: status, Elapsed: elapsed, NeedsReboot: needsReboot}
		r.onResult(res)
		results = append(results, res)

		if ctx.Err() != nil || needsReboot {
			break
		}

+      if errors.Is(err, ErrNeedsReboot) {

+        // status remains "OK": step completed, reboot is just required to continue

-    res := Result{Step: step, Status: status, Elapsed: elapsed}

+    res := Result{Step: step, Status: status, Elapsed: elapsed, NeedsReboot: needsReboot}

+    if ctx.Err() != nil || needsReboot {


Now find the `return cmd.Wait()` line and update it:


250		for scanner.Scan() {
251			line := scanner.Text()
252			if skipPSNoiseLine(line) {
253				continue
254			}
255			r.onLog(LogLine{
256				StepID: step.ID,
257				Text:   line,
258				Level:  parseLevel(line),
259			})
260		}
261
262		return cmd.Wait()
263	}
264
265	// skipPSNoiseLine returns true for PowerShell stderr noise that clutters the log:
266	// multi-line error blocks (At line:N, CategoryInfo, FullyQualifiedErrorId, etc.),
267	// blank lines, and VERBOSE: prefix lines already handled by Write-Log.
268	func skipPSNoiseLine(line string) bool {
269		trimmed := strings.TrimSpace(line)
270		if trimmed == "" {
271			return true
272		}
273		for _, prefix := range []string{
274			"At line:",
275			"+ CategoryInfo",
276			"+ FullyQualifiedErrorId",
277			"+ PositionMessage",
278			"VERBOSE:",
279			"DEBUG:",

	for scanner.Scan() {
		line := scanner.Text()
		if skipPSNoiseLine(line) {
			continue
		}
		r.onLog(LogLine{
			StepID: step.ID,
			Text:   line,
			Level:  parseLevel(line),
		})
	}

	return cmd.Wait()
}

// skipPSNoiseLine returns true for PowerShell stderr noise that clutters the log:
// multi-line error blocks (At line:N, CategoryInfo, FullyQualifiedErrorId, etc.),
// blank lines, and VERBOSE: prefix lines already handled by Write-Log.
func skipPSNoiseLine(line string) bool {
	trimmed := strings.TrimSpace(line)
	if trimmed == "" {
		return true
	}
	for _, prefix := range []string{
		"At line:",
		"+ CategoryInfo",
		"+ FullyQualifiedErrorId",
		"+ PositionMessage",
		"VERBOSE:",
		"DEBUG:",


	return cmd.Wait()
}

// skipPSNoiseLine

	waitErr := cmd.Wait()
	if waitErr != nil {
		var exitErr *exec.ExitError
		if errors.As(waitErr, &exitErr) && exitErr.ExitCode() == 9 {
			return ErrNeedsReboot
		}
	}
	return waitErr
}

// skipPSNoiseLine


	return cmd.Wait()
}

// skipPSNoiseLine

	waitErr := cmd.Wait()
	if waitErr != nil {
		var exitErr *exec.ExitError
		if errors.As(waitErr, &exitErr) && exitErr.ExitCode() == 9 {
			return ErrNeedsReboot
		}
	}
	return waitErr
}

// skipPSNoiseLine

+    if errors.As(waitErr, &exitErr) && exitErr.ExitCode() == 9 {

// skipPSNoiseLine returns true for PowerShell stderr noise that clutters the log:


Now add StepsByIDs and StepByID before WriteConfig:


310		if strings.Contains(line, "] [STEP]") {
311			return "STEP"
312		}
313		return "INFO"
314	}
315
316	// ExtractScripts unpacks embedded scripts to a temp directory.
317	// Returns the directory path. Caller is responsible for cleanup.
318	func ExtractScripts(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(string) ([]byte, error) }, tmpDir string) error {
319		entries, err := fs.ReadDir("scripts")
320		if err != nil {
321			return fmt.Errorf("read embedded scripts: %w", err)
322		}
323		scriptsDir := filepath.Join(tmpDir, "scripts")
324		if err := os.MkdirAll(scriptsDir, 0755); err != nil {
325			return err
326		}
327		for _, e := range entries {
328			if e.IsDir() {
329				continue

	if strings.Contains(line, "] [STEP]") {
		return "STEP"
	}
	return "INFO"
}

// ExtractScripts unpacks embedded scripts to a temp directory.
// Returns the directory path. Caller is responsible for cleanup.
func ExtractScripts(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(string) ([]byte, error) }, tmpDir string) error {
	entries, err := fs.ReadDir("scripts")
	if err != nil {
		return fmt.Errorf("read embedded scripts: %w", err)
	}
	scriptsDir := filepath.Join(tmpDir, "scripts")
	if err := os.MkdirAll(scriptsDir, 0755); err != nil {
		return err
	}
	for _, e := range entries {
		if e.IsDir() {
			continue


370			}
371			if err := os.WriteFile(dstPath, data, 0644); err != nil {
372				return err
373			}
374		}
375		return nil
376	}
377
378	// WriteConfig serialises cfg to a temp JSON file and returns its path.
379	func WriteConfig(cfg interface{}, tmpDir string) (string, error) {
380		path := filepath.Join(tmpDir, "config-runtime.json")
381		data, err := json.MarshalIndent(cfg, "", "  ")
382		if err != nil {
383			return "", err
384		}
385		return path, os.WriteFile(path, data, 0644)
386	}
387

		}
		if err := os.WriteFile(dstPath, data, 0644); err != nil {
			return err
		}
	}
	return nil
}

// WriteConfig serialises cfg to a temp JSON file and returns its path.
func WriteConfig(cfg interface{}, tmpDir string) (string, error) {
	path := filepath.Join(tmpDir, "config-runtime.json")
	data, err := json.MarshalIndent(cfg, "", "  ")
	if err != nil {
		return "", err
	}
	return path, os.WriteFile(path, data, 0644)
}


// WriteConfig serialises cfg to a temp JSON file and returns its path.
func WriteConfig(cfg interface{}, tmpDir string) (string, error) {

// StepsByIDs returns steps from AllSteps() whose IDs are in the given list,
// preserving the canonical AllSteps() order.
func StepsByIDs(ids []string) []Step {
	want := make(map[string]bool, len(ids))
	for _, id := range ids {
		want[id] = true
	}
	var result []Step
	for _, s := range AllSteps() {
		if want[s.ID] {
			result = append(result, s)
		}
	}
	return result
}

// StepByID returns the Step with the given ID, and true if found.
func StepByID(id string) (Step, bool) {
	for _, s := range AllSteps() {
		if s.ID == id {
			return s, true
		}
	}
	return Step{}, false
}

// WriteConfig serialises cfg to a temp JSON file and returns its path.
func WriteConfig(cfg interface{}, tmpDir string) (string, error) {


// StepsByIDs returns steps from AllSteps() whose IDs are in the given list,
// preserving the canonical AllSteps() order.
func StepsByIDs(ids []string) []Step {
	want := make(map[string]bool, len(ids))
	for _, id := range ids {
		want[id] = true
	}
	var result []Step
	for _, s := range AllSteps() {
		if want[s.ID] {
			result = append(result, s)
		}
	}
	return result
}

// StepByID returns the Step with the given ID, and true if found.
func StepByID(id string) (Step, bool) {
	for _, s := range AllSteps() {
		if s.ID == id {
			return s, true
		}
	}
	return Step{}, false
}

// WriteConfig serialises cfg to a temp JSON file and returns its path.
func WriteConfig(cfg interface{}, tmpDir string) (string, error) {

+// StepsByIDs returns steps from AllSteps() whose IDs are in the given list,

+// preserving the canonical AllSteps() order.

+  want := make(map[string]bool, len(ids))

+// StepByID returns the Step with the given ID, and true if found.

+func StepByID(id string) (Step, bool) {

path := filepath.Join(tmpDir, "config-runtime.json")


Now also check the Runner struct - it was modified in a previous session to add `onStepStart`. Let me verify:


145		Text   string
146		Level  string // INFO, OK, ERROR, WARN, STEP - parsed from [LEVEL] prefix
147	}
148
149	// Runner executes deployment steps sequentially.
150	type Runner struct {
151		cfg      RunConfig
152		onLog    func(LogLine)
153		onResult func(Result)
154		cancel   context.CancelFunc
155	}
156
157	// New creates a Runner. onLog is called for each output line, onResult after each step.
158	func New(cfg RunConfig, onLog func(LogLine), onResult func(Result)) *Runner {
159		return &Runner{cfg: cfg, onLog: onLog, onResult: onResult}
160	}
161
162	// Run executes enabled steps sequentially. Blocks until done or context cancelled.
163	func (r *Runner) Run(ctx context.Context, steps []Step) []Result {
164		ctx, cancel := context.WithCancel(ctx)

	Text   string
	Level  string // INFO, OK, ERROR, WARN, STEP - parsed from [LEVEL] prefix
}

// Runner executes deployment steps sequentially.
type Runner struct {
	cfg      RunConfig
	onLog    func(LogLine)
	onResult func(Result)
	cancel   context.CancelFunc
}

// New creates a Runner. onLog is called for each output line, onResult after each step.
func New(cfg RunConfig, onLog func(LogLine), onResult func(Result)) *Runner {
	return &Runner{cfg: cfg, onLog: onLog, onResult: onResult}
}

// Run executes enabled steps sequentially. Blocks until done or context cancelled.
func (r *Runner) Run(ctx context.Context, steps []Step) []Result {
	ctx, cancel := context.WithCancel(ctx)


The runner got reverted to a state without `onStepStart`. My new gui.go calls `runner.New` with an `onStepStart` callback. Let me add it back:


// Runner executes deployment steps sequentially.
type Runner struct {
	cfg      RunConfig
	onLog    func(LogLine)
	onResult func(Result)
	cancel   context.CancelFunc
}

// New creates a Runner. onLog is called for each output line, onResult after each step.
func New(cfg RunConfig, onLog func(LogLine), onResult func(Result)) *Runner {
	return &Runner{cfg: cfg, onLog: onLog, onResult: onResult}
}

// Runner executes deployment steps sequentially.
type Runner struct {
	cfg         RunConfig
	onLog       func(LogLine)
	onStepStart func(Step)
	onResult    func(Result)
	cancel      context.CancelFunc
}

// New creates a Runner. onLog is called for each output line, onResult after each step.
// onStepStart (optional) is called immediately before a step's script is launched.
func New(cfg RunConfig, onLog func(LogLine), onStepStart func(Step), onResult func(Result)) *Runner {
	return &Runner{cfg: cfg, onLog: onLog, onStepStart: onStepStart, onResult: onResult}
}


// Runner executes deployment steps sequentially.
type Runner struct {
	cfg      RunConfig
	onLog    func(LogLine)
	onResult func(Result)
	cancel   context.CancelFunc
}

// New creates a Runner. onLog is called for each output line, onResult after each step.
func New(cfg RunConfig, onLog func(LogLine), onResult func(Result)) *Runner {
	return &Runner{cfg: cfg, onLog: onLog, onResult: onResult}
}

// Runner executes deployment steps sequentially.
type Runner struct {
	cfg         RunConfig
	onLog       func(LogLine)
	onStepStart func(Step)
	onResult    func(Result)
	cancel      context.CancelFunc
}

// New creates a Runner. onLog is called for each output line, onResult after each step.
// onStepStart (optional) is called immediately before a step's script is launched.
func New(cfg RunConfig, onLog func(LogLine), onStepStart func(Step), onResult func(Result)) *Runner {
	return &Runner{cfg: cfg, onLog: onLog, onStepStart: onStepStart, onResult: onResult}
}

-func New(cfg RunConfig, onLog func(LogLine), onResult func(Result)) *Runner {

-  return &Runner{cfg: cfg, onLog: onLog, onResult: onResult}

+// onStepStart (optional) is called immediately before a step's script is launched.

+func New(cfg RunConfig, onLog func(LogLine), onStepStart func(Step), onResult func(Result)) *Runner {

+  return &Runner{cfg: cfg, onLog: onLog, onStepStart: onStepStart, onResult: onResult}

// Run executes enabled steps sequentially. Blocks until done or context cancelled.


Now add the `onStepStart` call in `Run()`:


175			if !step.Enabled {
176				res := Result{Step: step, Status: "SKIPPED"}
177				r.onResult(res)
178				results = append(results, res)
179				continue
180			}
181
182			start := time.Now()
183			err := r.runScript(ctx, step, cfgArg)
184			elapsed := time.Since(start)
185
186			status := "OK"
187			needsReboot := false
188			if err != nil {
189				if errors.Is(err, ErrNeedsReboot) {
190					needsReboot = true
191					// status remains "OK": step completed, reboot is just required to continue
192				} else if ctx.Err() != nil {
193					status = "CANCELLED"
194				} else {

		if !step.Enabled {
			res := Result{Step: step, Status: "SKIPPED"}
			r.onResult(res)
			results = append(results, res)
			continue
		}

		start := time.Now()
		err := r.runScript(ctx, step, cfgArg)
		elapsed := time.Since(start)

		status := "OK"
		needsReboot := false
		if err != nil {
			if errors.Is(err, ErrNeedsReboot) {
				needsReboot = true
				// status remains "OK": step completed, reboot is just required to continue
			} else if ctx.Err() != nil {
				status = "CANCELLED"
			} else {


		start := time.Now()
		err := r.runScript(ctx, step, cfgArg)
		elapsed := time.Since(start)

		if r.onStepStart != nil {
			r.onStepStart(step)
		}
		start := time.Now()
		err := r.runScript(ctx, step, cfgArg)
		elapsed := time.Since(start)


		if r.onStepStart != nil {
			r.onStepStart(step)
		}
		start := time.Now()
		err := r.runScript(ctx, step, cfgArg)
		elapsed := time.Since(start)


Now re-apply the gui.go and main.go (they were already written to disk by Write tool, let me verify they're still there):


Bash

grep -c "resumePhase\|prereboot\|state\.Load" /opt/xetup/internal/gui/gui.go

Check if gui.go has new content


grep -c "state\.Load\|state\.Exists" /opt/xetup/cmd/xetup/main.go

Check if main.go has new content


Both were reverted. Re-applying: