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.
- package/README.md +137 -6
- package/client.js +387 -0
- package/cordis.patch.yml +7 -0
- package/docs/README.zh.md +83 -5
- package/docs/platform-matrix.md +92 -0
- package/docs/rescue-drill.md +606 -0
- package/index.js +598 -3
- package/lib/diagnostics.js +8 -0
- package/lib/rescue-paths.js +253 -0
- package/lib/rescue.js +596 -0
- package/lib/scheduler-capability.js +300 -0
- package/lib/watchdog-arbiter.js +117 -0
- package/lib/watchdog-schedule.js +310 -0
- package/lib/watchdog-tier.js +182 -0
- package/lib/watchdog.js +479 -0
- package/locale/en.json +28 -1
- package/locale/zh.json +28 -1
- package/package.json +1 -1
- package/scripts/README.md +65 -0
- package/scripts/drill-rescue.ps1 +508 -0
- package/scripts/emergency-toggle.ps1 +136 -65
- package/scripts/restore-profile.cmd +44 -0
- package/scripts/restore-profile.ps1 +608 -0
- package/scripts/run-tests.ps1 +1 -1
|
@@ -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
|
package/scripts/run-tests.ps1
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# dsh-autorestart
|
|
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):
|