dsh-autorestart 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,608 @@
1
+ <#
2
+ .SYNOPSIS
3
+ dsh-autorestart profile snapshot restorer (half of the rescue kit).
4
+
5
+ .DESCRIPTION
6
+ When DSH does not come up (a plugin upgrade broke the profile, the
7
+ configuration was damaged, node_modules is a mess), this script puts the
8
+ profile back the way the last snapshot found it. It does not need DSH itself
9
+ and it does not need node.
10
+
11
+ It does four things, in this order:
12
+
13
+ 1. find the snapshot to restore (by default: the newest one under
14
+ <stateDir>\backups\ that carries a manifest);
15
+ 2. MOVE the current profile to <profileDir>.broken-<yyyyMMdd-HHmmss> first
16
+ - never overwritten, never deleted, so the evidence survives;
17
+ 3. copy the configuration files and the plugin tree back;
18
+ 4. read the result back: package.json parses, dsh.profile.bundles has
19
+ content, the version of dsh-autorestart can be read. Anything that does
20
+ not hold is reported honestly, together with the directory that still
21
+ holds the old profile.
22
+
23
+ Two hard rules:
24
+
25
+ 1. ONLY DSH PROFILE DIRECTORIES ARE TOUCHED: by default only
26
+ <user home>\.dsh\profiles\*; even an explicitly named -ProfileDir is
27
+ refused when it is a drive root, a system directory or somebody else's
28
+ user directory. A target that does not look like a DSH profile (no
29
+ package.json, or a package.json without a dsh.profile section) is
30
+ REFUSED.
31
+ 2. DSH IS NEVER RESTARTED FOR YOU: the script only puts the files back.
32
+ A script that "rescues" you and hangs the host again is useless.
33
+
34
+ LANGUAGE: this file is pure ASCII on purpose. Windows PowerShell 5.1 reads a
35
+ UTF-8 file without a byte order mark as ANSI, so a single Chinese character
36
+ inside the script turns into mojibake and the script dies with a
37
+ ParserError before it does anything. The Chinese wording therefore lives in
38
+ scripts\rescue-strings.zh.json (UTF-8, no BOM) and is read at runtime; if
39
+ that file is missing or broken, every message falls back to the English text
40
+ below and the script says so.
41
+
42
+ .EXAMPLE
43
+ powershell -NoProfile -ExecutionPolicy Bypass -File .\restore-profile.ps1
44
+
45
+ .EXAMPLE
46
+ powershell -NoProfile -ExecutionPolicy Bypass -File .\restore-profile.ps1 -DryRun
47
+
48
+ .EXAMPLE
49
+ powershell -NoProfile -ExecutionPolicy Bypass -File .\restore-profile.ps1 -ProfileDir C:\temp\drill\profile -StateDir C:\temp\drill\state
50
+
51
+ .NOTES
52
+ Windows PowerShell 5.1 is enough (nothing from 6.0+ is used); the .cmd entry
53
+ point double-clicked by the user is that one too. The same rules exist in
54
+ lib/rescue.js (node can import and test them directly), and
55
+ scripts/drill-rescue.ps1 proves that both behave the same way.
56
+ #>
57
+ [CmdletBinding()]
58
+ param(
59
+ # Only print what would happen, change nothing.
60
+ [switch] $DryRun,
61
+
62
+ # Use this snapshot (default: the newest one that carries a manifest).
63
+ [string] $Snapshot = '',
64
+
65
+ # The profile directory to restore into (default: the desktop one).
66
+ [string] $ProfileDir = '',
67
+
68
+ # The plugin state directory (default: <DSH_HOME>\storages\dsh-autorestart).
69
+ [string] $StateDir = ''
70
+ )
71
+
72
+ $ErrorActionPreference = 'Stop'
73
+
74
+ # The name of this plugin, in the profile and in the plugin tree.
75
+ $PackageName = 'dsh-autorestart'
76
+
77
+ # Where the old profile is kept after it is moved away (the user is told
78
+ # about it when something fails, so it lives in the script scope).
79
+ $script:BrokenDir = ''
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # The Chinese strings: read from rescue-strings.zh.json, fall back to English.
83
+ # ---------------------------------------------------------------------------
84
+
85
+ $script:Strings = $null
86
+ $script:StringsError = ''
87
+
88
+ function Initialize-Strings {
89
+ $path = Join-Path $PSScriptRoot 'rescue-strings.zh.json'
90
+ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
91
+ $script:StringsError = (T 'common.stringsMissingEn' 'the Chinese strings file is missing: {0} (falling back to English messages)') -f $path
92
+ return
93
+ }
94
+ try {
95
+ # Read as UTF-8 explicitly: Get-Content would use the console code page.
96
+ $text = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)
97
+ $script:Strings = $text | ConvertFrom-Json
98
+ } catch {
99
+ $script:StringsError = (T 'common.stringsBrokenEn' 'the Chinese strings file could not be read: {0} (falling back to English messages)') -f $_.Exception.Message
100
+ $script:Strings = $null
101
+ }
102
+ }
103
+
104
+ # Look a string up. The English fallback is right here in the script, so a
105
+ # missing key (or a broken strings file) never stops the rescue.
106
+ function T {
107
+ param([string] $Key, [string] $Fallback)
108
+ if ($null -ne $script:Strings) {
109
+ $node = $script:Strings
110
+ foreach ($part in $Key.Split('.')) {
111
+ if ($null -eq $node) { break }
112
+ $property = $node.PSObject.Properties[$part]
113
+ if ($null -eq $property) { $node = $null; break }
114
+ $node = $property.Value
115
+ }
116
+ if ($null -ne $node) {
117
+ $text = [string] $node
118
+ if ($text -ne '') { return $text }
119
+ }
120
+ }
121
+ return $Fallback
122
+ }
123
+
124
+ Initialize-Strings
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Output
128
+ # ---------------------------------------------------------------------------
129
+
130
+ function Say {
131
+ param([string] $Text, [string] $Color = 'Gray')
132
+ Write-Host $Text -ForegroundColor $Color
133
+ }
134
+
135
+ function Fail {
136
+ param([string] $Text)
137
+ Write-Host ((T 'restore.failPrefix' 'restore failed: ') + $Text) -ForegroundColor Red
138
+ if ($script:BrokenDir -ne '') {
139
+ Write-Host ((T 'restore.brokenKept' 'your old profile is still here: {0}') -f $script:BrokenDir) -ForegroundColor Yellow
140
+ Write-Host (T 'restore.brokenUntouched' '(that directory was not touched, it still looks like it did before the failure.)') -ForegroundColor Yellow
141
+ }
142
+ exit 1
143
+ }
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Path helpers
147
+ # ---------------------------------------------------------------------------
148
+
149
+ # An absolute path without the trailing separator ("C:\" keeps it).
150
+ function Resolve-FullPath {
151
+ param([string] $Path)
152
+ if ($null -eq $Path) { return '' }
153
+ $trimmed = ([string]$Path).Trim()
154
+ if ($trimmed -eq '') { return '' }
155
+ try {
156
+ $full = [System.IO.Path]::GetFullPath($trimmed)
157
+ } catch {
158
+ return ''
159
+ }
160
+ if ($full.Length -gt 3) { $full = $full.TrimEnd([char[]] @('\', '/')) }
161
+ return $full
162
+ }
163
+
164
+ # Are two paths the same one? Windows does not care about the case.
165
+ function Test-SamePath {
166
+ param([string] $A, [string] $B)
167
+ return (Resolve-FullPath $A).ToLowerInvariant() -eq (Resolve-FullPath $B).ToLowerInvariant()
168
+ }
169
+
170
+ # Is $Child inside $Parent (not counting $Parent itself)?
171
+ function Test-IsInside {
172
+ param([string] $Child, [string] $Parent)
173
+ $child = (Resolve-FullPath $Child).ToLowerInvariant()
174
+ $parent = (Resolve-FullPath $Parent).ToLowerInvariant()
175
+ if ($child -eq '' -or $parent -eq '') { return $false }
176
+ if ($child -eq $parent) { return $false }
177
+ if (-not $parent.EndsWith('\')) { $parent = $parent + '\' }
178
+ return $child.StartsWith($parent)
179
+ }
180
+
181
+ # The system directories: the environment first, the system drive as fallback.
182
+ function Get-SystemDirectories {
183
+ $list = New-Object System.Collections.Generic.List[string]
184
+ $add = {
185
+ param([string] $Value)
186
+ if ($null -eq $Value) { return }
187
+ $trimmed = ([string]$Value).Trim().TrimEnd([char[]] @('\', '/'))
188
+ if ($trimmed -eq '') { return }
189
+ if (-not $list.Contains($trimmed)) { $list.Add($trimmed) }
190
+ }
191
+ & $add $env:SystemRoot
192
+ & $add $env:windir
193
+ & $add $env:ProgramFiles
194
+ & $add ${env:ProgramFiles(x86)}
195
+ & $add $env:ProgramData
196
+ $drive = 'C:'
197
+ if (-not [string]::IsNullOrWhiteSpace($env:SystemDrive)) { $drive = $env:SystemDrive.Trim() }
198
+ & $add ($drive + '\Windows')
199
+ & $add ($drive + '\Program Files')
200
+ & $add ($drive + '\Program Files (x86)')
201
+ & $add ($drive + '\ProgramData')
202
+ & $add ($drive + '\$Recycle.Bin')
203
+ & $add ($drive + '\System Volume Information')
204
+ return $list
205
+ }
206
+
207
+ # Why may this path not be touched? An empty string means: it may.
208
+ function Get-ForbiddenReason {
209
+ param([string] $Target, [string] $HomeDir)
210
+ $dir = Resolve-FullPath $Target
211
+ if ($dir -eq '') { return (T 'restore.gateEmptyPath' 'the target path is empty') }
212
+ if ($dir -match '^[A-Za-z]:[\\/]?$') { return (T 'restore.gateDriveRoot' 'refusing to touch a drive root') }
213
+ $fsRoot = [System.IO.Path]::GetPathRoot($dir)
214
+ if ($fsRoot -ne '' -and $dir.ToLowerInvariant() -eq $fsRoot.ToLowerInvariant()) { return (T 'restore.gateFsRoot' 'refusing to touch a filesystem root') }
215
+ # Not $home: that one is a read-only automatic variable in PowerShell.
216
+ $homePath = Resolve-FullPath $HomeDir
217
+ if ($homePath -ne '' -and (Test-SamePath $dir $homePath)) { return (T 'restore.gateHome' 'refusing to touch the home directory itself') }
218
+ foreach ($system in Get-SystemDirectories) {
219
+ if ((Test-SamePath $dir $system) -or (Test-IsInside $dir $system)) {
220
+ return ((T 'restore.gateSystemDir' 'refusing to touch the system directory: {0}') -f $system)
221
+ }
222
+ }
223
+ # Somebody else's user directory: <Users>\<somebody else>.
224
+ if ($homePath -ne '') {
225
+ $usersRoot = Resolve-FullPath (Join-Path ([System.IO.Path]::GetPathRoot($homePath)) 'Users')
226
+ if ($usersRoot -ne '' -and (Test-IsInside $dir $usersRoot)) {
227
+ if ((Resolve-FullPath (Split-Path -Parent $dir)).ToLowerInvariant() -eq $usersRoot.ToLowerInvariant()) {
228
+ return (T 'restore.gateOtherUser' "refusing to touch another user's profile")
229
+ }
230
+ }
231
+ }
232
+ return ''
233
+ }
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Directories: DSH home / profiles / state / backups
237
+ # ---------------------------------------------------------------------------
238
+
239
+ function Get-DshHome {
240
+ if (-not [string]::IsNullOrWhiteSpace($env:DSH_HOME)) {
241
+ return (Resolve-FullPath $env:DSH_HOME)
242
+ }
243
+ return (Resolve-FullPath (Join-Path $env:USERPROFILE '.dsh'))
244
+ }
245
+
246
+ function Get-ProfilesRoot {
247
+ return (Resolve-FullPath (Join-Path (Get-DshHome) 'profiles'))
248
+ }
249
+
250
+ # The snapshot directory.
251
+ function Get-BackupsRoot {
252
+ param([string] $State)
253
+ if ($State -ne '') {
254
+ return (Resolve-FullPath (Join-Path (Resolve-FullPath $State) 'backups'))
255
+ }
256
+ return (Resolve-FullPath (Join-Path (Join-Path (Get-DshHome) 'storages\dsh-autorestart') 'backups'))
257
+ }
258
+
259
+ # Without -ProfileDir: where is the desktop profile?
260
+ function Get-DefaultProfileDir {
261
+ # 1) The environment variable the host exports.
262
+ if (-not [string]::IsNullOrWhiteSpace($env:DSH_PROFILE_DIR)) {
263
+ $explicit = Resolve-FullPath $env:DSH_PROFILE_DIR
264
+ if ($explicit -ne '' -and (Test-Path -LiteralPath $explicit -PathType Container)) { return $explicit }
265
+ }
266
+ # 2) The only profile that has this plugin in it - that is the one.
267
+ $root = Get-ProfilesRoot
268
+ if (Test-Path -LiteralPath $root -PathType Container) {
269
+ $matches = New-Object System.Collections.Generic.List[string]
270
+ foreach ($entry in (Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue)) {
271
+ $pkg = Join-Path $entry.FullName 'package.json'
272
+ if (-not (Test-Path -LiteralPath $pkg -PathType Leaf)) { continue }
273
+ try {
274
+ $document = ([System.IO.File]::ReadAllText($pkg)) | ConvertFrom-Json
275
+ } catch {
276
+ continue
277
+ }
278
+ if ($null -eq $document -or $null -eq $document.dsh -or $null -eq $document.dsh.profile) { continue }
279
+ $bundles = @()
280
+ if ($null -ne $document.dsh.profile.bundles) { $bundles = @($document.dsh.profile.bundles) }
281
+ if ($bundles -contains $PackageName) { $matches.Add($entry.FullName) }
282
+ }
283
+ if ($matches.Count -eq 1) { return $matches[0] }
284
+ }
285
+ # 3) The profile name the host asked for.
286
+ if (-not [string]::IsNullOrWhiteSpace($env:DSH_PROFILE)) {
287
+ return (Resolve-FullPath (Join-Path $root $env:DSH_PROFILE.Trim()))
288
+ }
289
+ # 4) The default name on the desktop.
290
+ return (Resolve-FullPath (Join-Path $root 'desktop'))
291
+ }
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # The profile fingerprint: does this directory still look like a DSH profile?
295
+ # ---------------------------------------------------------------------------
296
+
297
+ function Get-ProfileFingerprint {
298
+ param([string] $Dir)
299
+ $base = Resolve-FullPath $Dir
300
+ $markers = New-Object System.Collections.Generic.List[string]
301
+ $pkgPath = Join-Path $base 'package.json'
302
+ $hasPackageJson = Test-Path -LiteralPath $pkgPath -PathType Leaf
303
+ if ($hasPackageJson) { $markers.Add('package.json') }
304
+ if (Test-Path -LiteralPath (Join-Path $base 'cordis.patch.yml') -PathType Leaf) { $markers.Add('cordis.patch.yml') }
305
+ if (Test-Path -LiteralPath (Join-Path $base 'node_modules') -PathType Container) { $markers.Add('node_modules') }
306
+
307
+ $kind = 'not-a-profile'
308
+ $reason = (T 'restore.fingerprintNotProfile' 'there is no package.json here - this does not look like a DSH profile')
309
+ if ($hasPackageJson) {
310
+ $parsed = $null
311
+ try {
312
+ $parsed = ([System.IO.File]::ReadAllText($pkgPath)) | ConvertFrom-Json
313
+ } catch {
314
+ $parsed = $null
315
+ }
316
+ if ($null -ne $parsed -and $null -ne $parsed.dsh -and $null -ne $parsed.dsh.profile) {
317
+ $kind = 'profile'
318
+ $reason = (T 'restore.fingerprintProfile' 'the profile package.json carries a dsh.profile section')
319
+ } elseif ($markers.Count -ge 2) {
320
+ $kind = 'broken-profile'
321
+ $reason = (T 'restore.fingerprintBrokenNoDsh' 'the profile is broken (its package.json does not identify a profile), but the profile files are still there')
322
+ } else {
323
+ $reason = (T 'restore.fingerprintNotDshProfile' 'the package.json does not carry a dsh.profile section, and nothing else marks this as a profile')
324
+ }
325
+ } elseif ($markers.Count -gt 0) {
326
+ $kind = 'broken-profile'
327
+ $reason = (T 'restore.fingerprintBrokenNoPkg' 'the profile is broken (its package.json is missing), but the profile files are still there')
328
+ }
329
+ return [pscustomobject]@{ Dir = $base; Kind = $kind; Reason = $reason; Markers = @($markers) }
330
+ }
331
+
332
+ # ---------------------------------------------------------------------------
333
+ # The snapshot: the newest one that carries a manifest
334
+ # ---------------------------------------------------------------------------
335
+
336
+ function Read-SnapshotManifest {
337
+ param([string] $Dir)
338
+ $manifestPath = Join-Path (Resolve-FullPath $Dir) 'manifest.json'
339
+ if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { return $null }
340
+ try {
341
+ $text = [System.IO.File]::ReadAllText($manifestPath)
342
+ if ($text.Length -gt 0 -and [int] $text[0] -eq 0xFEFF) { $text = $text.Substring(1) }
343
+ $parsed = $text | ConvertFrom-Json
344
+ } catch {
345
+ return $null
346
+ }
347
+ if ($null -eq $parsed) { return $null }
348
+ return $parsed
349
+ }
350
+
351
+ function Select-Snapshot {
352
+ param([string] $BackupsDir)
353
+ $root = Resolve-FullPath $BackupsDir
354
+ $result = [pscustomobject]@{ Ok = $false; Dir = ''; Name = ''; Manifest = $null; Candidates = @(); Error = '' }
355
+ if (-not (Test-Path -LiteralPath $root -PathType Container)) {
356
+ $result.Error = ((T 'restore.snapshotDirMissing' 'the snapshot directory cannot be found: {0}') -f $root)
357
+ return $result
358
+ }
359
+ $candidates = New-Object System.Collections.Generic.List[object]
360
+ foreach ($entry in (Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue)) {
361
+ $manifest = Read-SnapshotManifest $entry.FullName
362
+ if ($null -eq $manifest) { continue }
363
+ $at = $null
364
+ if ($null -ne $manifest.at) {
365
+ try { $at = [int64] $manifest.at } catch { $at = $null }
366
+ }
367
+ $candidates.Add([pscustomobject]@{ Name = $entry.Name; Dir = $entry.FullName; Manifest = $manifest; At = $at })
368
+ }
369
+ $result.Candidates = @($candidates | ForEach-Object { $_.Name })
370
+ if ($candidates.Count -eq 0) {
371
+ $result.Error = ((T 'restore.snapshotNone' 'there is no snapshot with a manifest under {0}') -f $root)
372
+ return $result
373
+ }
374
+ $sorted = @($candidates | Sort-Object -Property `
375
+ @{ Expression = { if ($null -eq $_.At) { [int64] 0 } else { $_.At } }; Descending = $true }, `
376
+ @{ Expression = { $_.Name }; Descending = $true })
377
+ $best = $sorted[0]
378
+ $result.Ok = $true
379
+ $result.Dir = $best.Dir
380
+ $result.Name = $best.Name
381
+ $result.Manifest = $best.Manifest
382
+ return $result
383
+ }
384
+
385
+ # Does this snapshot hold a real DSH profile? (It has to: we write it into a
386
+ # profile directory.)
387
+ function Test-SnapshotProfile {
388
+ param([string] $SnapshotDir)
389
+ $pkgPath = Join-Path (Join-Path (Resolve-FullPath $SnapshotDir) 'config') 'package.json'
390
+ if (-not (Test-Path -LiteralPath $pkgPath -PathType Leaf)) {
391
+ return [pscustomobject]@{ Ok = $false; Reason = (T 'restore.snapshotNoConfigPkg' 'this snapshot has no config\package.json') }
392
+ }
393
+ $parsed = $null
394
+ try {
395
+ $parsed = ([System.IO.File]::ReadAllText($pkgPath)) | ConvertFrom-Json
396
+ } catch {
397
+ return [pscustomobject]@{ Ok = $false; Reason = (T 'restore.snapshotBadConfigPkg' 'the package.json of this snapshot is not valid JSON') }
398
+ }
399
+ if ($null -eq $parsed -or $null -eq $parsed.dsh -or $null -eq $parsed.dsh.profile) {
400
+ return [pscustomobject]@{ Ok = $false; Reason = (T 'restore.snapshotNoDshProfile' 'the package.json of this snapshot has no dsh.profile section') }
401
+ }
402
+ return [pscustomobject]@{ Ok = $true; Reason = (T 'restore.snapshotIsProfile' 'this snapshot holds a DSH profile') }
403
+ }
404
+
405
+ # The time the snapshot says it was taken (in words).
406
+ function Get-SnapshotDisplay {
407
+ param($Manifest)
408
+ $display = (T 'restore.snapshotUnknownTime' 'an unknown time')
409
+ $includePlugins = $false
410
+ if ($null -ne $Manifest) {
411
+ if ($null -ne $Manifest.atLocal -and ([string] $Manifest.atLocal).Trim() -ne '') {
412
+ $display = (([string] $Manifest.atLocal).Trim() -replace 'T', ' ').Substring(0, 16)
413
+ } elseif ($null -ne $Manifest.at) {
414
+ try { $display = (Get-Date -Date ([DateTimeOffset]::FromUnixTimeMilliseconds([int64] $Manifest.at).LocalDateTime) -Format 'yyyy-MM-dd HH:mm') } catch { $display = (T 'restore.snapshotUnknownTime' 'an unknown time') }
415
+ }
416
+ $includePlugins = ($Manifest.includePlugins -eq $true)
417
+ }
418
+ return [pscustomobject]@{ Display = $display; IncludePlugins = $includePlugins }
419
+ }
420
+
421
+ # ---------------------------------------------------------------------------
422
+ # Start
423
+ # ---------------------------------------------------------------------------
424
+
425
+ if ($script:StringsError -ne '') {
426
+ Write-Host ('WARNING: ' + $script:StringsError) -ForegroundColor Yellow
427
+ }
428
+
429
+ Say (T 'restore.title' 'dsh-autorestart - profile snapshot restorer') 'Cyan'
430
+
431
+ $explicitProfile = ($ProfileDir -as [string]) -ne ''
432
+ if ($explicitProfile) {
433
+ $targetProfile = Resolve-FullPath $ProfileDir
434
+ } else {
435
+ $targetProfile = Get-DefaultProfileDir
436
+ }
437
+ if ($targetProfile -eq '') { Fail (T 'restore.noProfileDir' 'the profile directory to restore into cannot be found (use -ProfileDir to say which one).') }
438
+
439
+ # --- Gate 1: is the path itself safe? ---
440
+ $forbidden = Get-ForbiddenReason $targetProfile $env:USERPROFILE
441
+ if ($forbidden -ne '') { Fail ($forbidden + ((T 'restore.targetSuffix' '(target: {0})') -f $targetProfile)) }
442
+
443
+ if (-not $explicitProfile) {
444
+ $profilesRoot = Get-ProfilesRoot
445
+ if (-not (Test-IsInside $targetProfile $profilesRoot)) {
446
+ Fail ((T 'restore.onlyProfiles' 'only a profile under {0} may be restored (target: {1})') -f $profilesRoot, $targetProfile)
447
+ }
448
+ }
449
+ Say ((T 'restore.target' 'target profile: {0}') -f $targetProfile)
450
+
451
+ # --- Gate 2: the target profile directory has to exist ---
452
+ # Restoring means putting a snapshot back into a profile that is ALREADY there,
453
+ # not inventing one. The script never creates a profiles directory: that would
454
+ # only turn a mistyped target into an empty shell profile.
455
+ if (-not (Test-Path -LiteralPath $targetProfile -PathType Container)) {
456
+ Fail ((T 'restore.missingDir' 'the target profile directory does not exist: {0} (the restore needs that directory to be there; the script does not create a profile directory for it)') -f $targetProfile)
457
+ }
458
+
459
+ # --- Gate 3: does this directory still look like a DSH profile? ---
460
+ $fingerprint = Get-ProfileFingerprint $targetProfile
461
+ if ($fingerprint.Kind -eq 'not-a-profile') {
462
+ Fail ($fingerprint.Reason + ((T 'restore.targetSuffix' '(target: {0})') -f $targetProfile) + (T 'restore.notProfileSuffix' '. Refusing to act - a directory that is not a profile must not be damaged.'))
463
+ }
464
+ if ($fingerprint.Kind -eq 'broken-profile') {
465
+ Say ((T 'restore.brokenNotice' 'this profile is already broken: {0}') -f $fingerprint.Reason) 'Yellow'
466
+ }
467
+
468
+ # --- Find the snapshot ---
469
+ if (($Snapshot -as [string]) -ne '') {
470
+ $snapshotDir = Resolve-FullPath $Snapshot
471
+ if (-not (Test-Path -LiteralPath $snapshotDir -PathType Container)) {
472
+ Fail ((T 'restore.snapshotMissing' 'this snapshot cannot be found: {0}') -f $snapshotDir)
473
+ }
474
+ $manifest = Read-SnapshotManifest $snapshotDir
475
+ if ($null -eq $manifest) { Fail ((T 'restore.snapshotNoManifest' 'this snapshot has no manifest.json, so it is not a snapshot: {0}') -f $snapshotDir) }
476
+ $selected = [pscustomobject]@{ Ok = $true; Dir = $snapshotDir; Name = (Split-Path -Leaf $snapshotDir); Manifest = $manifest }
477
+ } else {
478
+ $backupsRoot = Get-BackupsRoot $StateDir
479
+ Say ((T 'restore.backupsDir' 'snapshot directory: {0}') -f $backupsRoot)
480
+ $selected = Select-Snapshot $backupsRoot
481
+ if (-not $selected.Ok) { Fail $selected.Error }
482
+ $snapshotDir = $selected.Dir
483
+ }
484
+ $manifest = $selected.Manifest
485
+
486
+ # --- Gate 4: does the snapshot hold a real profile? ---
487
+ $snapshotProfile = Test-SnapshotProfile $snapshotDir
488
+ if (-not $snapshotProfile.Ok) { Fail ($snapshotProfile.Reason + ((T 'restore.snapshotSuffix' '(snapshot: {0})') -f $snapshotDir)) }
489
+
490
+ $described = Get-SnapshotDisplay $manifest
491
+ Say ((T 'restore.usingSnapshot' 'snapshot to use: {0} ({1})') -f $selected.Name, $described.Display)
492
+
493
+ # --- Work out the name of the evidence directory for the old profile ---
494
+ $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
495
+ $script:BrokenDir = $targetProfile + '.broken-' + $stamp
496
+ $counter = 2
497
+ while (Test-Path -LiteralPath $script:BrokenDir) {
498
+ $script:BrokenDir = $targetProfile + '.broken-' + $stamp + '-' + $counter
499
+ $counter += 1
500
+ }
501
+
502
+ # --- Dry run ---
503
+ if ($DryRun) {
504
+ Say ((T 'restore.dryRunMove' '[dry run] the current profile would be moved to: {0}') -f $script:BrokenDir) 'Cyan'
505
+ Say ((T 'restore.dryRunCopy' '[dry run] the configuration and the plugins of {0} would be copied back to: {1}') -f $selected.Name, $targetProfile) 'Cyan'
506
+ Say ((T 'restore.dryRunVerify' '[dry run] package.json / dsh.profile.bundles / the version of {0} would be read back.') -f $PackageName) 'Cyan'
507
+ Say (T 'restore.dryRunNoChange' '[dry run] no file was changed.') 'Cyan'
508
+ exit 0
509
+ }
510
+
511
+ # --- Move the current profile away (never overwrites, never deletes) ---
512
+ if (Test-Path -LiteralPath $targetProfile) {
513
+ try {
514
+ Move-Item -LiteralPath $targetProfile -Destination $script:BrokenDir -ErrorAction Stop
515
+ } catch {
516
+ Fail ((T 'restore.moveFailed' 'moving the current profile away failed (nothing was changed): {0}') -f $_.Exception.Message)
517
+ }
518
+ Say ((T 'restore.moved' 'the old one has been moved to: {0}') -f $script:BrokenDir) 'Gray'
519
+ } else {
520
+ Say (T 'restore.noProfileBefore' 'the profile directory was not there, it is created.') 'Gray'
521
+ }
522
+
523
+ # --- Copy it back ---
524
+ try {
525
+ New-Item -ItemType Directory -Path $targetProfile -Force | Out-Null
526
+ $configDir = Join-Path $snapshotDir 'config'
527
+ if (Test-Path -LiteralPath $configDir -PathType Container) {
528
+ foreach ($item in (Get-ChildItem -LiteralPath $configDir -Force)) {
529
+ Copy-Item -LiteralPath $item.FullName -Destination (Join-Path $targetProfile $item.Name) -Recurse -Force -ErrorAction Stop
530
+ }
531
+ }
532
+ $pluginsDir = Join-Path $snapshotDir 'node_modules'
533
+ if (Test-Path -LiteralPath $pluginsDir -PathType Container) {
534
+ Copy-Item -LiteralPath $pluginsDir -Destination (Join-Path $targetProfile 'node_modules') -Recurse -Force -ErrorAction Stop
535
+ }
536
+ } catch {
537
+ Fail ((T 'restore.copyFailed' 'copying the snapshot back failed (the old one is still at {0}): {1}') -f $script:BrokenDir, $_.Exception.Message)
538
+ }
539
+
540
+ # --- Read the result back ---
541
+ $checks = New-Object System.Collections.Generic.List[object]
542
+
543
+ $pkgPath = Join-Path $targetProfile 'package.json'
544
+ $parsed = $null
545
+ if (-not (Test-Path -LiteralPath $pkgPath -PathType Leaf)) {
546
+ $checks.Add([pscustomobject]@{ Label = (T 'restore.verifyPkgLabel' 'package.json can be parsed'); Status = 'failed'; Detail = (T 'restore.verifyPkgMissing' 'package.json is missing') })
547
+ } else {
548
+ try {
549
+ $parsed = ([System.IO.File]::ReadAllText($pkgPath)) | ConvertFrom-Json
550
+ $checks.Add([pscustomobject]@{ Label = (T 'restore.verifyPkgLabel' 'package.json can be parsed'); Status = 'passed'; Detail = '' })
551
+ } catch {
552
+ $checks.Add([pscustomobject]@{ Label = (T 'restore.verifyPkgLabel' 'package.json can be parsed'); Status = 'failed'; Detail = (T 'restore.verifyPkgBad' 'not valid JSON') })
553
+ }
554
+ }
555
+
556
+ $bundles = $null
557
+ if ($null -ne $parsed -and $null -ne $parsed.dsh -and $null -ne $parsed.dsh.profile -and $null -ne $parsed.dsh.profile.bundles) {
558
+ $bundles = @($parsed.dsh.profile.bundles)
559
+ }
560
+ if ($null -ne $bundles -and $bundles.Count -gt 0) {
561
+ $checks.Add([pscustomobject]@{ Label = (T 'restore.verifyBundlesLabel' 'dsh.profile.bundles has content'); Status = 'passed'; Detail = ((T 'restore.verifyBundlesCount' '{0} in total') -f $bundles.Count) })
562
+ } else {
563
+ $checks.Add([pscustomobject]@{ Label = (T 'restore.verifyBundlesLabel' 'dsh.profile.bundles has content'); Status = 'failed'; Detail = (T 'restore.verifyBundlesEmpty' 'there is no dsh.profile.bundles, or it is empty') })
564
+ }
565
+
566
+ # The plugin version: only when the profile says it wants the plugin.
567
+ # It says it, but node_modules does not have it - that is a really broken
568
+ # profile ("the configuration says one thing, the disk another") and it has to
569
+ # fail.
570
+ $declaresPlugin = ($null -ne $bundles -and ($bundles -contains $PackageName))
571
+ $pluginPkgPath = Join-Path $targetProfile ('node_modules\' + $PackageName + '\package.json')
572
+ $pluginLabel = ((T 'restore.verifyPluginLabel' 'the version of {0} can be read') -f $PackageName)
573
+ if (-not $declaresPlugin) {
574
+ $checks.Add([pscustomobject]@{ Label = $pluginLabel; Status = 'skipped'; Detail = ((T 'restore.verifyPluginNotDeclared' 'this profile does not list {0} (there is nothing to read)') -f $PackageName) })
575
+ } elseif (-not (Test-Path -LiteralPath $pluginPkgPath -PathType Leaf)) {
576
+ $checks.Add([pscustomobject]@{ Label = $pluginLabel; Status = 'failed'; Detail = ((T 'restore.verifyPluginMissing' 'the profile lists {0}, but node_modules\{0} is not there - the configuration says one thing and the disk another') -f $PackageName, $PackageName) })
577
+ } else {
578
+ $version = ''
579
+ try {
580
+ $pluginPkg = ([System.IO.File]::ReadAllText($pluginPkgPath)) | ConvertFrom-Json
581
+ if ($null -ne $pluginPkg -and $null -ne $pluginPkg.version) { $version = ([string] $pluginPkg.version).Trim() }
582
+ } catch {
583
+ $version = ''
584
+ }
585
+ if ($version -ne '') {
586
+ $checks.Add([pscustomobject]@{ Label = $pluginLabel; Status = 'passed'; Detail = $version })
587
+ } else {
588
+ $checks.Add([pscustomobject]@{ Label = $pluginLabel; Status = 'failed'; Detail = (T 'restore.verifyPluginNoVersion' 'the version number cannot be read') })
589
+ }
590
+ }
591
+
592
+ foreach ($check in $checks) {
593
+ if ($check.Status -eq 'passed') { Say ((T 'restore.checkOk' ' [ok] {0} - {1}') -f $check.Label, $check.Detail) 'Green' }
594
+ elseif ($check.Status -eq 'skipped') { Say ((T 'restore.checkSkipped' ' [skipped] {0} - {1}') -f $check.Label, $check.Detail) 'Yellow' }
595
+ else { Say ((T 'restore.checkBad' ' [bad] {0} - {1}') -f $check.Label, $check.Detail) 'Red' }
596
+ }
597
+
598
+ $failed = @($checks | Where-Object { $_.Status -eq 'failed' })
599
+ if ($failed.Count -gt 0) {
600
+ Fail ((T 'restore.verifyFailed' 'the restored profile did not pass the check ({0}).') -f (($failed | ForEach-Object { $_.Label }) -join ', '))
601
+ }
602
+
603
+ # --- One sentence a human can act on ---
604
+ $pluginNote = (T 'restore.noteWithPlugins' '(with the plugins)')
605
+ if (-not $described.IncludePlugins) { $pluginNote = (T 'restore.noteNoPlugins' '(configuration files only, no plugins)') }
606
+ Say ((T 'restore.done' 'restored to the snapshot of {0}{1}; you can open DSH again now.') -f $described.Display, $pluginNote) 'Green'
607
+ Say ((T 'restore.brokenLeft' 'the old one was left at: {0} (delete it yourself once you are sure)') -f $script:BrokenDir) 'Gray'
608
+ exit 0
@@ -1,4 +1,4 @@
1
- # dsh-autorestart — run the plugin's node:test suite with the DSH bundled Node.
1
+ # dsh-autorestart - run the plugin's node:test suite with the DSH bundled Node.
2
2
  #
3
3
  # There is no `node` on PATH in the DSH desktop environment, so this script uses
4
4
  # the runtime that ships with the app. Usage (from anywhere):