Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ winget export -o apps.json --source winget

For personal usage, keep `apps.json`, `optional-apps.json`, and `config\backup.json` out of git. The repo ships `apps-template.json`, `optional-apps-template.json`, and `config\backup.template.json`, and the backup workflow preserves your personal files so they can be restored into the cloned repo after reinstall.

Normal restore keeps current personal files and saves differing backup copies in
`Recovered from backup`. Backup configuration requires `-UseBackupSettings` and
confirmation after preview; bootstrap does not apply it automatically. Application
state requires an explicit app selection with the app closed. See
[restore modes and selections](docs/BACKUP-FORMAT.md#restore-conflicts-and-selections).

If you want a second-stage app list, create `optional-apps.json` alongside `apps.json`. Start from `optional-apps-template.json` if you want an example. `apps.json` installs automatically during bootstrap, while `optional-apps.json` is offered with a yes/no prompt after first login and can also be installed later from a desktop shortcut.

### Backup Before Reinstall
Expand Down
59 changes: 3 additions & 56 deletions bootstrap.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -1314,54 +1314,6 @@ function Ensure-CanonicalRepo {
return $false
}

function Restore-RepoFilesFromManifest {
param(
[object]$Manifest,
[string]$ManifestPath = $script:BackupManifestPath
)

if (-not $Manifest) {
return $false
}

if (-not $Manifest.repoFiles) {
return $true
}

try {
$actualBackupRoot = Split-Path -Parent (Resolve-Path -LiteralPath $ManifestPath -ErrorAction Stop).Path
$manifestBackupRoot = Get-BackupManifestRoot -Manifest $Manifest
}
catch {
Write-Log "Cannot resolve the selected backup manifest: $($_.Exception.Message)" -Level WARNING
return $false
}

$restored = $true
foreach ($repoFile in $Manifest.repoFiles) {
try {
$source = Resolve-BackupSourcePath -Path $repoFile.backupPath -ManifestBackupRoot $manifestBackupRoot -ActualBackupRoot $actualBackupRoot
if (-not (Test-Path -LiteralPath $source -PathType Leaf)) {
throw "Backup source file is missing: $source"
}

$destination = Join-Path $CanonicalRepoPath $repoFile.relativePath
$destinationParent = Split-Path -Path $destination -Parent
if (-not (Test-Path -LiteralPath $destinationParent)) {
New-Item -Path $destinationParent -ItemType Directory -Force -ErrorAction Stop | Out-Null
}

Copy-Item -LiteralPath $source -Destination $destination -Force -ErrorAction Stop
}
catch {
Write-Log "Failed to restore repo file '$($repoFile.relativePath)': $($_.Exception.Message)" -Level WARNING
$restored = $false
}
}

return $restored
}

function Get-RunBootstrapTarget {
if (Test-Path $CanonicalBootstrap) {
return $CanonicalBootstrap
Expand Down Expand Up @@ -1460,14 +1412,9 @@ try {
Set-StepState -StepId $stepId -Status "skipped" -Message "Backup manifest not found; using C:\Setup fallback"
}
elseif (Ensure-CanonicalRepo -Manifest $manifest) {
if (Restore-RepoFilesFromManifest -Manifest $manifest) {
Add-SummaryItem -Step "Repo" -Status "OK" -Message "Canonical repo ready at $CanonicalRepoPath"
Set-StepState -StepId $stepId -Status "done" -Message "Canonical repo ready"
}
else {
Add-SummaryItem -Step "Repo" -Status "FAIL" -Message "Personal repo file restore incomplete; retry setup after correcting the backup"
Set-StepState -StepId $stepId -Status "failed" -Message "Personal repo file restore incomplete"
}
Write-Log "Backup settings remain separate. Use Restore My Files with -UseBackupSettings to preview and select them." -Level INFO
Add-SummaryItem -Step "Repo" -Status "OK" -Message "Canonical repo ready at $CanonicalRepoPath; backup settings require explicit restore"
Set-StepState -StepId $stepId -Status "done" -Message "Canonical repo ready; backup settings not applied"
}
else {
Write-Log "Canonical repo unavailable; continuing with C:\Setup assets" -Level WARNING
Expand Down
96 changes: 94 additions & 2 deletions docs/BACKUP-FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ Content destinations may be explicit absolute paths, including redirected folder
All selected restore paths and source existence checks run before the first
restore write. Traversal, rooted repository-relative paths, alternate streams,
and reparse points in path ancestors are rejected. For a junction or symbolic
link root, configure its physical target folder. Robocopy excludes junctions
within copied trees. Backup source and session paths must not overlap.
link root, configure its physical target folder. Backup copying excludes junctions;
restore rejects reparse points inside selected trees. Backup sources, restore
destinations, and recovered-file locations must not overlap the selected backup.

## Restore mappings

Expand Down Expand Up @@ -50,3 +51,94 @@ Legacy manifests without this object remain restorable with an explicit warning
that complete verification is unavailable. Any existing repository `sha256`
values are validated before restore. Hashes detect corruption; the manifest is
not signed and is not an authentication mechanism.

## Restore conflicts and selections

`restore-backup.ps1` resolves a file plan, displays it, and executes those same
decisions. `-Preview` and `-WhatIf` return the plan without creating directories,
copying files, prompting, writing a report, or opening Explorer. The shared
`New-RestorePlan` function is the restore-policy input for the broader console
preview tracked in #83; that issue's setup preview remains separate work.

| Selection | Personal files | Project settings | Application state |
| --- | --- | --- | --- |
| Default, or `-Mode Merge` | Restore missing files, keep identical files, save differing backups separately | Keep current configuration; retain differing or missing backup settings separately | Skip until the app is explicitly selected |
| `-Mode SkipExisting` | Leave every existing file untouched without making a conflict copy | Skip existing files; retain missing backup settings separately | Still requires a separate app selection |
| `-Mode Overwrite` | Preview every replacement and require `YES` before applying it | Still requires `-UseBackupSettings` | Still requires `-RestoreApp` |
| `-UseBackupSettings` | Uses the selected personal-file mode | Preview and confirm applying backup configuration, including `apps.json`, optional apps, and Windows preferences | Does not select app state |
| `-RestoreApp <id>` | Uses the selected personal-file mode | Does not select project settings | Preview and confirm restoring all complete roots for that app, with its processes closed |

`-Force` is retained for command compatibility and does not alter these rules or
bypass confirmation. `-Confirm:$false` also cannot bypass the explicit `YES`.
Declining confirmation cancels the entire run before writes. `SkipExisting`
continues to skip existing project files even with `-UseBackupSettings`.
`-IncludeTags` filters ordinary content rules; repository settings remain visible
and separately selected. Explicit app selections include every related app rule,
independently of tags, so a database cannot be partly restored by tag filtering.

Project files inside the canonical repository are protected even when they were
incidentally included in a content rule. Bootstrap may clone the repository but
never copies backup configuration into it automatically. Applying backup settings
can change what a later setup run installs or configures; review their contents
before selecting them. In particular, `config\backup.json` contains old-machine paths.

Recovered copies are stored beneath
`<destination profile>\Recovered from backup\<SHA256 of selected manifest>`, with
the original drive or UNC share and folder structure preserved underneath. Moving
an unchanged backup does not change its grouping. An identical recovered copy is
reused on a rerun. An edited recovered copy is kept; the backup is saved to a
stable `.backup-<content SHA256>` alternate. If that alternate was also edited,
restore stops during planning rather than replacing either copy.

Results distinguish `restored`, `already-present`, `conflicting-copy-saved`,
`skipped`, and `failed`. The JSON report is written inside that recovery grouping,
outside the original backup. Results print an **Open recovered files** command;
`-OpenRecoveredFiles` opens the same folder in Explorer after execution.

Each personal or project file is copied to a temporary sibling and hash checked
against the planned backup content before publication. Existing destinations are
checked again against the preview. Replacement uses the filesystem's file-replace
operation; copy or hash failures leave the current file intact. Legacy backups
still warn that original backup-time verification is unavailable, even though
the new copy is checked against the selected backup bytes.

## Application-state metadata

An application-state `knownFolders` or `extraPaths` entry can declare:

```json
"application": {
"id": "example-app",
"processNames": ["ExampleApp", "ExampleHelper"]
}
```

Use exact Windows process names without `.exe`, paths, or wildcards. Include all
processes that write the state. Backup persists this object on each corresponding
manifest rule. Give related settings, session, and database roots the same app ID.
Application backup consistency requires closing the application during backup too.

Restore stages and verifies all selected roots before replacing any of them. It
checks processes during planning and again before applying state, and refuses an
incomplete app backup or changed destination. A failed application of one root
rolls back the roots already applied. Previous state is retained in uniquely named
`.restore-<id>-previous` sibling directories, recorded in `previousPaths` in the
results. Keep the app closed until restoration finishes. This is a coordinated
restore with rollback, not a transaction across disks or protection from power loss.

Unclassified files beneath the recorded or destination profile's `AppData`, and
rules tagged `app-state` without application metadata, are skipped with an
explanation. State stored elsewhere must be declared with application metadata;
file extensions alone cannot reliably identify application databases. Legacy
app-state backups need this classification before an explicit app restore. App
installation and sign-in are separate from restoring files and saved state.

Examples, using the selected backup and destination profile:

```powershell
.\restore-backup.ps1 -ManifestPath E:\backup\backup-manifest.json -DestinationProfileRoot C:\Users\NewUser -Preview
.\restore-backup.ps1 -ManifestPath E:\backup\backup-manifest.json -DestinationProfileRoot C:\Users\NewUser -OpenRecoveredFiles
.\restore-backup.ps1 -ManifestPath E:\backup\backup-manifest.json -DestinationProfileRoot C:\Users\NewUser -Mode Overwrite
.\restore-backup.ps1 -ManifestPath E:\backup\backup-manifest.json -DestinationProfileRoot C:\Users\NewUser -UseBackupSettings
.\restore-backup.ps1 -ManifestPath E:\backup\backup-manifest.json -DestinationProfileRoot C:\Users\NewUser -RestoreApp example-app
```
14 changes: 14 additions & 0 deletions modules/BackupManifest.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ function Assert-BackupConfiguration {
if ($Config.$collection -isnot [array]) { throw "$collection must be an array." }
foreach ($entry in $Config.$collection) {
Assert-BackupObject $entry $collection
Assert-BackupApplication $entry.application
if ($entry.enabled -isnot [bool]) { throw "$collection.enabled must be a boolean." }
if ($null -ne $entry.required -and $entry.required -isnot [bool]) { throw "$collection.required must be a boolean." }
Assert-BackupStringList $entry.tags "$collection.tags"
Expand All @@ -225,6 +226,18 @@ function Assert-BackupConfiguration {
}
}

function Assert-BackupApplication {
param([object]$Application)
if ($null -eq $Application) { return }
Assert-BackupObject $Application 'application'
if ($Application.id -isnot [string] -or [string]::IsNullOrWhiteSpace($Application.id)) { throw 'application.id must be a non-empty string.' }
Assert-BackupStringList $Application.processNames 'application.processNames'
if (-not $Application.processNames.Count) { throw 'application.processNames must name the processes that must be closed.' }
foreach ($name in $Application.processNames) {
if ($name -match '[\\/:*?"<>|]' -or $name.EndsWith('.exe', [StringComparison]::OrdinalIgnoreCase)) { throw 'application.processNames must contain exact process names without paths, wildcards, or .exe.' }
}
}

function Assert-BackupManifest {
param([object]$Manifest)
Assert-BackupObject $Manifest 'Backup manifest'
Expand All @@ -239,6 +252,7 @@ function Assert-BackupManifest {
foreach ($entry in $Manifest.$name) {
Assert-BackupObject $entry $name
if ($name -eq 'rules') {
Assert-BackupApplication $entry.application
if ($entry.success -isnot [bool]) { throw 'rules.success must be a boolean.' }
# Earlier v1 producers serialized an absent optional tag list as [null].
if ($entry.tags -is [array] -and $entry.tags.Count -eq 1 -and $null -eq $entry.tags[0]) { $entry.tags = @() }
Expand Down
Loading
Loading