Přenos vývojářského Windows stroje: 24 PowerShell skriptů místo dvou dnů klikání

Nový notebook je hezká věc, dokud si nespočítáte, kolik hodin dá dostat ho do stavu, kdy jde skutečně pracovat. Napsal jsem si na to sadu skriptů – a cestou narazil na tři věci, které mi vzaly víc času než všechno ostatní dohromady.

Stažení ReinstallWindows.zip.

Proč ne obraz disku

Nabízí se klonovat disk a mít hotovo. Jenže cílem migrace pro mě není mít stejný stroj – je to jediná příležitost za pět let vyhodit balast. Deset let starých instalaček, cache po nástrojích, které už nepoužívám, tři verze Pythonu z toho jednu ruční. Obraz disku všechno tohle věrně přenese.

Druhý důvod je praktičtější: obraz je černá skříňka. Když za rok potřebuju vědět, kde se vlastně bere junction C:\work, chci to najít v očíslovaném skriptu, ne rekonstruovat z paměti. Ta sada je tím pádem zároveň dokumentace stroje.

Architektura: dvě poloviny a jeden USB disk

Skripty jsou rozdělené na dvě sady. OLD-PC\ se pouští na starém stroji a jenom čte – zálohuje a exportuje. NEW-PC\ se pouští na novém a zapisuje – instaluje a obnovuje. Obě strany mají společný parametr -Dest (u mě D:\Migrace), což je externí disk, který mezi stroji putuje fyzicky.

Číslování je pořadí spuštění. Zní to triviálně, ale u migrace to je zásadní – pár kroků na sobě tvrdě závisí a špatné pořadí znamená tichý polovičatý výsledek, kterého si všimnete až za týden.

Vrstva Export (OLD-PC) Import (NEW-PC)
Data a konfigurace ZIP zálohy 02–06 rozbalení do cílů
Aplikace winget export winget install po skupinách
Junctiony junctions.csv New-Item -ItemType Junction
IIS appcmd list → XML appcmd add + netsh http
Systém PATH, hosts, Wi-Fi, WSL sloučení / import

Past první: ZIP a reparse pointy

První verze zálohy wwwroot běžela přes Compress-Archive. Nechal jsem to běžet přes oběd, vrátil se – a archiv měl 180 GB a pořád rostl.

Důvod: wwwroot je prošpikovaný junctiony a symlinky. Adresář active\ obsahuje spojky, které routují domény na skutečné složky webů, wwwroot\etc míří rovnou do C:\Windows\System32\drivers\etc, a v každém node_modules po pnpm sedí stovky symlinků. Compress-Archive je všechny poslušně následuje. Výsledkem je masivní duplicita a v horším případě cyklus.

Řešení je nezávidět si a napsat si vlastní průchod stromem, který reparse pointy rozpozná a přeskočí – včetně jejich obsahu, na všech úrovních:

foreach ($item in Get-ChildItem -LiteralPath $dir -Force) {
    # ReparsePoint = junction i symlink; přeskoč i s obsahem
    if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
        $stats.SkippedLinks++
        continue
    }
    if ($item.PSIsContainer) { Add-Directory $item.FullName; continue }
    [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
        $zip, $item.FullName, $entry, $Level) | Out-Null
}

Když už jsem si ten walker psal, přešel jsem rovnou na System.IO.Compression.ZipFile místo Compress-Archive. Dva vedlejší efekty stojí za zmínku: podpora ZIP64 (žádný strop 2 GB / 65 535 položek) a možnost volit kompresi. U desítek gigabajtů zdrojáků a instalaček je Fastest podstatně rozumnější než Optimal – rozdíl ve velikosti je pár procent, rozdíl v čase násobky. NoCompression pak dělá z archivu prostý kontejner, což je nejrychlejší varianta, když házíte data na externí disk s dost místem.

Mimochodem

Ten samý mechanismus, který zálohu zachrání, ji zároveň děravuje. Přeskočený wwwroot\etc znamená, že v archivu není soubor hosts. Proto je záloha hosts samostatný krok. Stejně tak file-symlinky v konfiguraci se musí vyexportovat zvlášť jako seznam, protože jako reparse pointy ze zálohy vypadnou.

Past druhá: junctiony se obnovují až nakonec

Junctiony jsem neřešil jako data, ale jako manifest. Export projde definované rozsahy a zapíše CSV se čtyřmi sloupci – Scope, LinkType, Link, Target. Uživatelský profil se přitom normalizuje na %USERPROFILE%, aby manifest přežil jiné uživatelské jméno na novém stroji.

Podstatný detail je kdy se spojky vytvářejí. Junction v NTFS vyžaduje existující cíl. Když se pokusíte vytvořit C:\work → C:\inetpub\wwwroot dřív, než je wwwroot rozbalený, prostě to selže. A protože polovina mých spojek míří do OneDrive, musí být napřed dokončená i synchronizace – což u desítek gigabajtů není otázka minut.

Proto je 07-create-junctions až sedmý v pořadí, i když logicky patří na začátek. Skript navíc rozlišuje tři stavy cíle, aby bylo z výpisu vidět, co se povedlo:

if (Test-Path -LiteralPath $Link) {
    $it = Get-Item -LiteralPath $Link -Force
    if ($it.Attributes -band [IO.FileAttributes]::ReparsePoint) {
        Write-Host "  = už existuje (přeskakuji): $Link"   # idempotence
    } else {
        Write-Host "  ! POZOR: $Link existuje jako BĚŽNÁ složka - nevytvářím"
    }
    return
}

Ta druhá větev je důležitější, než vypadá. Když se do cesty spojky mezitím rozbalí skutečná složka, tichá záměna by znamenala, že pracujete na kopii dat místo na originále. Radši ať to zařve.

Symlinky po pnpm v node_modules neřeším vůbec – jsou jich tisíce a pnpm install je vyrobí znovu líp než já.

Past třetí (a nejdražší): IIS a HTTPS

Tohle mi vzalo nejvíc času a je to zároveň jediná část, kterou bych po sobě chtěl číst za rok.

Export a import IIS přes appcmd vypadá triviálně:

appcmd list apppool /config /xml > apppools.xml
appcmd list site    /config /xml > sites.xml
# na novém stroji:
appcmd add apppool /in < apppools.xml
appcmd add site    /in < sites.xml

Weby naskočí, v konzoli IIS všechno sedí, bindingy na :443 jsou na svém místě. A přesto na HTTPS nic nejede.

Důvod je, že HTTPS binding v IIS žije ve dvou vrstvách a appcmd přenáší jen jednu z nich:

  1. applicationHost.config – samotný binding IP:port:host plus odkaz na certifikát (certificateHash, certStoreName). Tohle appcmd přenese.
  2. HTTP.sys – skutečné mapování host:port → certifikát na úrovni jádra, spravované přes netsh http … sslcert. Tohle appcmd nepřenáší. Bez něj IIS certifikát prostě nenajde.

Řešení je vyexportovat si bindingy do CSV a na novém stroji je nasypat do HTTP.sys ručně. U SNI bindingů (což je dnes prakticky všechno) se používá hostnameport, u ne-SNI ipport:

# SNI binding
netsh http add sslcert hostnameport=dev.example.cz:443 `
      certhash=<thumbprint> `
      appid={4dc3e181-e14b-4a21-b022-59fc669b0914} `
      certstorename=WebHosting

# ne-SNI (IP:port)
netsh http add sslcert ipport=0.0.0.0:443 certhash=<thumbprint> appid=<guid>

To appid není nic tajného – je to standardní GUID služby IIS, stejný na každém stroji.

Certifikáty patří do WebHosting, ne do Osobní

Druhá půlka téhož problému. Když certifikát naimportujete do Místní počítač\Osobní (My), IIS ani HTTP.sys ho nenajdou a chybová hláška vám nepomůže. Správný store je Místní počítač\WebHosting – v certlm.msc, nebo přes Import-PfxCertificate -CertStoreLocation "Cert:\LocalMachine\WebHosting". Certifikát navíc musí mít privátní klíč, takže se přenáší jako PFX, ne jako CER.

Celý ten re-bind je idempotentní – netsh existující záznam přepíše, takže skript jde pustit opakovaně. Bindingy bez certifikátu se jenom nahlásí a přeskočí.

Sloučit, ne přepsat

U proměnných prostředí jsem si při první verzi ustřelil nohu. Naivní obnova PATH ze zálohy funguje přesně jednou – a to na stroji, kde ještě nic není nainstalované. Jakmile část aplikací doinstalujete přes winget dřív (a ony si do PATH samy zapíšou), přepis zálohou je zase odstraní.

Správně se PATH slučuje po segmentech, ne nahrazuje:

$curSeg = @([Environment]::GetEnvironmentVariable('Path',$scope) -split ';' | Where-Object { $_ })
$add    = @($val -split ';' | Where-Object { $_ -and $curSeg -notcontains $_ })
if ($add) {
    $new = (($curSeg + $add) -join ';')
    [Environment]::SetEnvironmentVariable('Path', $new, $scope)
}

Stejná logika platí pro hosts. Nový soubor se nekopíruje přes starý naslepo – původní se nejdřív odloží stranou s časovým razítkem. Za těch pár kilobajtů to stojí.

Co se záměrně nepřenáší

Podstatná část práce nebyla vymyslet, co zálohovat, ale co ne. Přenášet všechno je pomalé, křehké a hlavně to táhne na nový stroj přesně ten balast, kvůli kterému jsem migraci dělal.

Co Proč ne
OneDrive stáhne se online; junctiony do něj se vytvoří až potom
Outlook OST několik GB, kompletně se znovu vytvoří z M365
Globální npm balíčky reinstall je rychlejší a rovnou aktualizuje verze
pnpm symlinky pnpm install
Cache .cache, .gradle, VS Code History a workspaceStorage
Profily Chrome / Edge gigabajty; přihlásit účet a nechat nasynchronizovat
AVD image emulátoru stovky MB uvnitř .android, vytvoří se znovu

Jedna výjimka stojí za zmínku: rozšíření VS Code se naopak vyplatí přenést binárně. Nejsou v AppData, ale v ~\.vscode\extensions, a při migraci Win11 → Win11 na stejné architektuře fungují 1:1. Ušetří to reinstalaci i čekání na marketplace. Jako pojistku si přesto vyexportuju seznam:

code --list-extensions > vscode-extensions.txt
# obnova, kdyby binární kopie zlobila:
Get-Content vscode-extensions.txt | ForEach-Object { code --install-extension $_ }

Aplikace: winget ano, ale ne na všechno

Reinstalace aplikací jde přes winget po skupinách (vývoj, prohlížeče, nástroje, runtimy), aby šlo pouštět po částech a vidět, co selhalo. Skript má -WhatIfList, který jen vypíše příkazy a nic neinstaluje – u něčeho, co sahá na celý systém, chci vždycky nejdřív vidět suchý běh.

Dvě kategorie ale winget neobslouží:

  • Aplikace s vlastním umístěním. Git, Node, PHP, MariaDB, Apache a Python mám mimo Program Files, v jedné společné složce, protože je tam potřebuju mít pohromadě a verzovaně. Ty se neinstalují – obnoví se z archivu a jen zaregistrují služby a PATH. Winget by je nainstaloval jinam a rozbil konfiguraci.
  • Licencovaný software. Buď v repozitáři není, nebo tam je, ale aktivace stejně chce klíč z KeePass. Skript to nezkouší řešit – jen na konci vypíše seznam k doinstalování ručně, aby se na nic nezapomnělo.

Pořadí, které funguje

  1. Bootstrap PowerShellu 7 (pod starým 5.1, včetně Unblock-File na stažené skripty).
  2. Přihlásit OneDrive a nechat dojet synchronizaci. Ne, opravdu dojet.
  3. Aplikace přes winget, zapnout roli IIS.
  4. Rozbalit ZIP zálohy do cílů.
  5. Globální npm balíčky, proměnné prostředí, Wi-Fi, WSL, hosts.
  6. IIS – nejdřív -WhatIfList, pak ostrý import.
  7. Certifikáty do WebHosting a re-bind HTTP.sys.
  8. Až úplně nakonec junctiony a file-symlinky.

Kroky 2 a 8 spolu souvisí a je to ta nejčastější chyba, kterou jsem při ladění dělal. Junctiony míří do OneDrive a do obnovených dat; když je pustíte dřív, tiše selžou na neexistujícím cíli a vy je pak hledáte podle toho, že „nějak nefunguje jeden web".

Poznámka na závěr: ten archiv je bomba

Zálohu smažte, až doslouží

Migrační archiv obsahuje .ssh klíče, .git-credentials, tokeny cloudových CLI, exportované PFX s privátními klíči a XML profily Wi-Fi včetně hesel v čitelné podobě. Je to kompletní klíčenka k vašemu pracovnímu životu na jednom externím disku. Držte ho zašifrovaný, nenechávejte ho ležet v šuplíku „kdyby náhodou" a po dokončení migrace ho bezpečně smažte.

Nakonec to nejužitečnější, co z toho vypadlo, není samotná migrace – ta se dělá jednou za pár let. Je to ten manifest. Junctiony, IIS bindingy, seznam aplikací a PATH v očíslovaných souborech jsou mapa stroje, kterou jsem jinak měl jen v hlavě. Až mi příště umře disk, nezačínám od nuly, ale od kroku 1.


Skripty běží pod PowerShell 7, jsou uložené jako UTF-8 s BOM (kvůli diakritice ve výpisech) a začínají chcp 65001 plus nastavením [Console]::OutputEncoding. Bez toho se z české hlášky v konzoli stane guláš.