dsh-codex-subscription 0.2.8

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/dsh-codex.ps1 ADDED
@@ -0,0 +1,756 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [ValidateSet('Install', 'Update', 'Uninstall')]
4
+ [string] $Action = 'Install',
5
+
6
+ [ValidatePattern('^[A-Za-z0-9._-]+$')]
7
+ [string] $Profile = 'web',
8
+
9
+ [string] $PortableRoot,
10
+
11
+ [string] $CommandRoot,
12
+
13
+ [switch] $NoModifyPath,
14
+
15
+ [switch] $Managed,
16
+
17
+ [switch] $SkipSelfUpdate,
18
+
19
+ [switch] $DryRun
20
+ )
21
+
22
+ $ErrorActionPreference = 'Stop'
23
+ Set-StrictMode -Version 2.0
24
+ $Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
25
+ [Console]::OutputEncoding = $Utf8NoBom
26
+ $OutputEncoding = $Utf8NoBom
27
+
28
+ if ($Managed -and -not $PSBoundParameters.ContainsKey('Action')) {
29
+ Write-Host 'Usage: dsh-codex <install|update|uninstall>'
30
+ exit 0
31
+ }
32
+
33
+ $PackageName = 'dsh-codex-subscription'
34
+ $LegacyPackageName = '@wsl043/dsh-codex-subscription'
35
+ $PackageVersion = '0.2.8'
36
+ $PackageSpec = 'https://github.com/WSL043/dsh-codex-subscription/releases/download/v0.2.8/dsh-codex-subscription.tgz'
37
+ $PnpmVersion = '11.19.0'
38
+ $PnpmUrl = 'https://registry.npmjs.org/pnpm/-/pnpm-11.19.0.tgz'
39
+ $PnpmSha512 = '7881F3ED590D472C4A955E2B88B2121791116066DCC88CBCA3849EC9B60F1BBAA6D2CCB221FA91DA4E1C65BEF2BCBE379365AEA7AC539C7BF86DEDC3A1B22DCE'
40
+ $ReleaseApi = if ($env:DSH_CODEX_RELEASE_API) { $env:DSH_CODEX_RELEASE_API } else { 'https://api.github.com/repos/WSL043/dsh-codex-subscription/releases/latest' }
41
+ $ReleaseBase = if ($env:DSH_CODEX_RELEASE_BASE) { $env:DSH_CODEX_RELEASE_BASE.TrimEnd('/') } else { 'https://github.com/WSL043/dsh-codex-subscription/releases/download' }
42
+
43
+ function Get-FileDigest {
44
+ param(
45
+ [Parameter(Mandatory = $true)][string] $Path,
46
+ [ValidateSet('SHA256', 'SHA512')][string] $Algorithm
47
+ )
48
+
49
+ $hasher = if ($Algorithm -eq 'SHA512') {
50
+ [System.Security.Cryptography.SHA512]::Create()
51
+ } else {
52
+ [System.Security.Cryptography.SHA256]::Create()
53
+ }
54
+ $stream = [System.IO.File]::OpenRead($Path)
55
+ try {
56
+ return ([System.BitConverter]::ToString($hasher.ComputeHash($stream))).Replace('-', '')
57
+ } finally {
58
+ $stream.Dispose()
59
+ $hasher.Dispose()
60
+ }
61
+ }
62
+
63
+ function Get-ManagerCommandRoot {
64
+ if ($CommandRoot) { return Resolve-FullPath $CommandRoot }
65
+ if ($Managed -and $PSCommandPath) { return Split-Path -Parent (Resolve-FullPath $PSCommandPath) }
66
+ if (-not $env:LOCALAPPDATA) { throw 'LOCALAPPDATA is required to install the dsh-codex command.' }
67
+ return Join-Path $env:LOCALAPPDATA 'Programs\dsh-codex'
68
+ }
69
+
70
+ function Invoke-LatestManager {
71
+ param([Parameter(Mandatory = $true)][string] $InstalledCommandRoot)
72
+
73
+ Write-Host 'Checking the latest immutable release...'
74
+ $releaseResponse = Invoke-WebRequest -UseBasicParsing -Uri $ReleaseApi -Headers @{
75
+ Accept = 'application/vnd.github+json'
76
+ 'User-Agent' = 'dsh-codex'
77
+ }
78
+ try {
79
+ $release = $releaseResponse.Content | ConvertFrom-Json
80
+ } catch {
81
+ throw 'GitHub returned unreadable latest-release metadata.'
82
+ }
83
+ $tag = [string] $release.tag_name
84
+ if ($tag -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$') {
85
+ throw "GitHub returned an invalid release tag: $tag"
86
+ }
87
+
88
+ $stage = Join-Path ([System.IO.Path]::GetTempPath()) ('dsh-codex-update-' + [guid]::NewGuid().ToString('N'))
89
+ $latestScript = Join-Path $stage 'dsh-codex.ps1'
90
+ $checksumFile = Join-Path $stage 'dsh-codex.ps1.sha256'
91
+ New-Item -ItemType Directory -Path $stage | Out-Null
92
+ try {
93
+ $assetBase = "$ReleaseBase/$tag"
94
+ Invoke-WebRequest -UseBasicParsing -Uri "$assetBase/dsh-codex.ps1" -OutFile $latestScript
95
+ Invoke-WebRequest -UseBasicParsing -Uri "$assetBase/dsh-codex.ps1.sha256" -OutFile $checksumFile
96
+ $checksumText = Get-Content -LiteralPath $checksumFile -Raw
97
+ $match = [regex]::Match($checksumText, '(?im)^\s*([a-f0-9]{64})\s+\*?dsh-codex\.ps1\s*$')
98
+ if (-not $match.Success) { throw 'The release manager checksum file is invalid.' }
99
+ $expectedHash = $match.Groups[1].Value.ToUpperInvariant()
100
+ $actualHash = Get-FileDigest -Algorithm SHA256 -Path $latestScript
101
+ if ($actualHash -ne $expectedHash) {
102
+ throw "Release manager checksum mismatch. Expected $expectedHash, received $actualHash."
103
+ }
104
+
105
+ $arguments = @(
106
+ '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
107
+ '-File', $latestScript,
108
+ '-Managed', '-SkipSelfUpdate', '-Action', $Action,
109
+ '-Profile', $Profile,
110
+ '-CommandRoot', $InstalledCommandRoot
111
+ )
112
+ if ($PortableRoot) { $arguments += @('-PortableRoot', $PortableRoot) }
113
+ if ($NoModifyPath) { $arguments += '-NoModifyPath' }
114
+ & powershell.exe @arguments
115
+ if ($LASTEXITCODE -ne 0) { throw "The latest release manager failed with exit code $LASTEXITCODE." }
116
+ } finally {
117
+ if (Test-Path -LiteralPath $stage) {
118
+ Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue
119
+ }
120
+ }
121
+ }
122
+
123
+ function Test-SamePath {
124
+ param(
125
+ [Parameter(Mandatory = $true)][string] $Left,
126
+ [Parameter(Mandatory = $true)][string] $Right
127
+ )
128
+ try {
129
+ $leftPath = (Resolve-FullPath $Left).TrimEnd('\')
130
+ $rightPath = (Resolve-FullPath $Right).TrimEnd('\')
131
+ return [string]::Equals($leftPath, $rightPath, [System.StringComparison]::OrdinalIgnoreCase)
132
+ } catch {
133
+ return [string]::Equals($Left.Trim(), $Right.Trim(), [System.StringComparison]::OrdinalIgnoreCase)
134
+ }
135
+ }
136
+
137
+ function Add-UserPathEntry {
138
+ param(
139
+ [AllowNull()][AllowEmptyString()][string] $UserPath,
140
+ [Parameter(Mandatory = $true)][string] $Directory
141
+ )
142
+
143
+ foreach ($entry in @(([string] $UserPath).Split(';'))) {
144
+ if ($entry.Trim() -and (Test-SamePath -Left $entry -Right $Directory)) {
145
+ return [string] $UserPath
146
+ }
147
+ }
148
+ if (-not $UserPath) { return $Directory }
149
+
150
+ # Always add our own separator. If the existing value already ends in one,
151
+ # removing this suffix can still restore the original text byte-for-byte.
152
+ return $UserPath + ';' + $Directory
153
+ }
154
+
155
+ function Remove-UserPathEntry {
156
+ param(
157
+ [AllowNull()][AllowEmptyString()][string] $UserPath,
158
+ [Parameter(Mandatory = $true)][string] $Directory
159
+ )
160
+
161
+ if (-not $UserPath) { return [string] $UserPath }
162
+
163
+ $lastSeparator = $UserPath.LastIndexOf(';')
164
+ if ($lastSeparator -ge 0) {
165
+ $lastEntry = $UserPath.Substring($lastSeparator + 1)
166
+ if ($lastEntry.Trim() -and (Test-SamePath -Left $lastEntry -Right $Directory)) {
167
+ return $UserPath.Substring(0, $lastSeparator)
168
+ }
169
+ } elseif (Test-SamePath -Left $UserPath -Right $Directory) {
170
+ return ''
171
+ }
172
+
173
+ $kept = New-Object System.Collections.Generic.List[string]
174
+ foreach ($entry in $UserPath.Split(';')) {
175
+ if ($entry.Trim() -and (Test-SamePath -Left $entry -Right $Directory)) { continue }
176
+ $kept.Add($entry)
177
+ }
178
+ return $kept -join ';'
179
+ }
180
+
181
+ function Publish-UserPathChange {
182
+ try {
183
+ if (-not ('DshCodex.NativeMethods' -as [type])) {
184
+ Add-Type -TypeDefinition @'
185
+ using System;
186
+ using System.Runtime.InteropServices;
187
+ namespace DshCodex {
188
+ public static class NativeMethods {
189
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
190
+ public static extern IntPtr SendMessageTimeout(
191
+ IntPtr hWnd,
192
+ uint message,
193
+ UIntPtr wParam,
194
+ string lParam,
195
+ uint flags,
196
+ uint timeout,
197
+ out UIntPtr result);
198
+ }
199
+ }
200
+ '@
201
+ }
202
+ [UIntPtr] $result = [UIntPtr]::Zero
203
+ # HWND_BROADCAST + WM_SETTINGCHANGE with SMTO_ABORTIFHUNG.
204
+ [void] [DshCodex.NativeMethods]::SendMessageTimeout(
205
+ [IntPtr] 0xffff,
206
+ 0x001A,
207
+ [UIntPtr]::Zero,
208
+ 'Environment',
209
+ 0x0002,
210
+ 2000,
211
+ [ref] $result
212
+ )
213
+ } catch {
214
+ Write-Warning 'The user PATH was updated, but Windows did not accept the environment refresh notification.'
215
+ }
216
+ }
217
+
218
+ function Add-ManagerToUserPath {
219
+ param([Parameter(Mandatory = $true)][string] $Directory)
220
+
221
+ $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
222
+ $updated = Add-UserPathEntry -UserPath $userPath -Directory $Directory
223
+ if ([string]::Equals([string] $updated, [string] $userPath, [System.StringComparison]::Ordinal)) { return }
224
+ [Environment]::SetEnvironmentVariable('Path', $updated, 'User')
225
+ Publish-UserPathChange
226
+ }
227
+
228
+ function Remove-ManagerFromUserPath {
229
+ param([Parameter(Mandatory = $true)][string] $Directory)
230
+
231
+ $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
232
+ if (-not $userPath) { return }
233
+ $updated = Remove-UserPathEntry -UserPath $userPath -Directory $Directory
234
+ if ([string]::Equals([string] $updated, [string] $userPath, [System.StringComparison]::Ordinal)) { return }
235
+ [Environment]::SetEnvironmentVariable('Path', $updated, 'User')
236
+ Publish-UserPathChange
237
+ }
238
+
239
+ function Start-ManagerCleanup {
240
+ param([Parameter(Mandatory = $true)][string] $Directory)
241
+
242
+ $installedScript = Join-Path $Directory 'dsh-codex.ps1'
243
+ $installedShim = Join-Path $Directory 'dsh-codex.cmd'
244
+ if (-not (Test-Path -LiteralPath $installedScript -PathType Leaf) -or
245
+ -not (Test-Path -LiteralPath $installedShim -PathType Leaf)) {
246
+ throw 'Refusing to clean an unrecognized manager command directory.'
247
+ }
248
+
249
+ $cleanupScript = Join-Path ([System.IO.Path]::GetTempPath()) ('dsh-codex-cleanup-' + [guid]::NewGuid().ToString('N') + '.ps1')
250
+ $cleanup = @'
251
+ param(
252
+ [int] $ParentId,
253
+ [string] $DirectoryBase64,
254
+ [string] $CleanupBase64
255
+ )
256
+ $ErrorActionPreference = 'SilentlyContinue'
257
+ $directory = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($DirectoryBase64))
258
+ $cleanupScript = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($CleanupBase64))
259
+ try {
260
+ Wait-Process -Id $ParentId -ErrorAction SilentlyContinue
261
+ Start-Sleep -Milliseconds 300
262
+ Remove-Item -LiteralPath (Join-Path $directory 'dsh-codex.ps1') -Force -ErrorAction SilentlyContinue
263
+ Remove-Item -LiteralPath (Join-Path $directory 'dsh-codex.cmd') -Force -ErrorAction SilentlyContinue
264
+ if ((Test-Path -LiteralPath $directory -PathType Container) -and
265
+ -not (Get-ChildItem -LiteralPath $directory -Force -ErrorAction SilentlyContinue | Select-Object -First 1)) {
266
+ Remove-Item -LiteralPath $directory -Force -ErrorAction SilentlyContinue
267
+ }
268
+ } finally {
269
+ Remove-Item -LiteralPath $cleanupScript -Force -ErrorAction SilentlyContinue
270
+ }
271
+ '@
272
+ [System.IO.File]::WriteAllText($cleanupScript, $cleanup, $Utf8NoBom)
273
+ $directoryBase64 = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Directory))
274
+ $cleanupBase64 = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cleanupScript))
275
+ $argumentLine = "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$cleanupScript`" -ParentId $PID -DirectoryBase64 $directoryBase64 -CleanupBase64 $cleanupBase64"
276
+ Start-Process -FilePath 'powershell.exe' -ArgumentList $argumentLine -WindowStyle Hidden | Out-Null
277
+ }
278
+
279
+ function Remove-ManagerCommand {
280
+ param([Parameter(Mandatory = $true)][string] $Directory)
281
+
282
+ if (-not $NoModifyPath) { Remove-ManagerFromUserPath -Directory $Directory }
283
+ if (Test-Path -LiteralPath $Directory -PathType Container) {
284
+ $installedScript = Join-Path $Directory 'dsh-codex.ps1'
285
+ if ($Managed -and $PSCommandPath -and (Test-SamePath -Left $PSCommandPath -Right $installedScript)) {
286
+ Start-ManagerCleanup -Directory $Directory
287
+ return
288
+ }
289
+ foreach ($ownedFile in @('dsh-codex.ps1', 'dsh-codex.cmd')) {
290
+ $ownedPath = Join-Path $Directory $ownedFile
291
+ if (Test-Path -LiteralPath $ownedPath -PathType Leaf) {
292
+ Remove-Item -LiteralPath $ownedPath -Force
293
+ }
294
+ }
295
+ if (-not (Get-ChildItem -LiteralPath $Directory -Force -ErrorAction SilentlyContinue | Select-Object -First 1)) {
296
+ Remove-Item -LiteralPath $Directory -Force
297
+ }
298
+ }
299
+ }
300
+
301
+ function Install-ManagerCommand {
302
+ param([Parameter(Mandatory = $true)][string] $Directory)
303
+
304
+ if (-not $PSCommandPath -or -not (Test-Path -LiteralPath $PSCommandPath -PathType Leaf)) {
305
+ throw 'The manager command can only be installed from a downloaded script file.'
306
+ }
307
+ New-Item -ItemType Directory -Force -Path $Directory | Out-Null
308
+ $installedScript = Join-Path $Directory 'dsh-codex.ps1'
309
+ if (-not (Test-SamePath -Left $PSCommandPath -Right $installedScript)) {
310
+ $stagedScript = Join-Path $Directory ('.dsh-codex-' + [guid]::NewGuid().ToString('N') + '.ps1')
311
+ try {
312
+ Copy-Item -LiteralPath $PSCommandPath -Destination $stagedScript
313
+ Move-Item -LiteralPath $stagedScript -Destination $installedScript -Force
314
+ } finally {
315
+ if (Test-Path -LiteralPath $stagedScript) {
316
+ Remove-Item -LiteralPath $stagedScript -Force -ErrorAction SilentlyContinue
317
+ }
318
+ }
319
+ }
320
+
321
+ $shim = @'
322
+ @echo off
323
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0dsh-codex.ps1" -Managed %*
324
+ exit /b %ERRORLEVEL%
325
+ '@
326
+ [System.IO.File]::WriteAllText((Join-Path $Directory 'dsh-codex.cmd'), $shim, [System.Text.Encoding]::ASCII)
327
+ if (-not $NoModifyPath) { Add-ManagerToUserPath -Directory $Directory }
328
+ }
329
+
330
+ function Resolve-FullPath {
331
+ param([Parameter(Mandatory = $true)][string] $Path)
332
+ return [System.IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($Path))
333
+ }
334
+
335
+ function Get-PortableLayout {
336
+ param([Parameter(Mandatory = $true)][string] $Root)
337
+
338
+ $resolvedRoot = Resolve-FullPath $Root
339
+ $node = Join-Path $resolvedRoot 'runtime\node\node.exe'
340
+ $dsh = Join-Path $resolvedRoot 'app\node_modules\@deepseek-ai\dsh\lib\bin.js'
341
+ $portableCli = Join-Path $resolvedRoot 'dsh.exe'
342
+ if (-not (Test-Path -LiteralPath $node -PathType Leaf) -or
343
+ -not (Test-Path -LiteralPath $dsh -PathType Leaf)) {
344
+ return $null
345
+ }
346
+
347
+ $stateRoot = $resolvedRoot
348
+ $installedMode = Join-Path $resolvedRoot 'installed-mode.json'
349
+ if (Test-Path -LiteralPath $installedMode -PathType Leaf) {
350
+ $mode = Get-Content -LiteralPath $installedMode -Raw | ConvertFrom-Json
351
+ if (-not $mode.stateRoot) { throw "Invalid installed-mode.json: stateRoot is missing." }
352
+ $stateRoot = Resolve-FullPath ([string] $mode.stateRoot)
353
+ }
354
+
355
+ return [pscustomobject]@{
356
+ Root = $resolvedRoot
357
+ StateRoot = $stateRoot
358
+ Node = $node
359
+ Dsh = $dsh
360
+ PortableCli = if (Test-Path -LiteralPath $portableCli -PathType Leaf) { $portableCli } else { $null }
361
+ DshHome = Join-Path $stateRoot 'data\dsh-home'
362
+ }
363
+ }
364
+
365
+ function New-PortableTarget {
366
+ param([Parameter(Mandatory = $true)] $Layout)
367
+ $usesPortableCli = $null -ne $Layout.PortableCli
368
+ return [pscustomobject]@{
369
+ Mode = 'portable'
370
+ Layout = $Layout
371
+ Executable = if ($usesPortableCli) { $Layout.PortableCli } else { $Layout.Node }
372
+ Node = $Layout.Node
373
+ UsesPortableCli = $usesPortableCli
374
+ }
375
+ }
376
+
377
+ function Find-PortableFromCurrentDirectory {
378
+ $directory = [System.IO.DirectoryInfo]::new((Get-Location).Path)
379
+ while ($null -ne $directory) {
380
+ $layout = Get-PortableLayout $directory.FullName
381
+ if ($null -ne $layout) { return $layout }
382
+ $directory = $directory.Parent
383
+ }
384
+ return $null
385
+ }
386
+
387
+ function Find-RunningPortables {
388
+ try {
389
+ $processes = Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" -ErrorAction Stop
390
+ foreach ($process in $processes) {
391
+ $executable = [string] $process.ExecutablePath
392
+ if (-not $executable) { continue }
393
+ $nodeDirectory = Split-Path -Parent $executable
394
+ $runtimeDirectory = Split-Path -Parent $nodeDirectory
395
+ $root = Split-Path -Parent $runtimeDirectory
396
+ $layout = Get-PortableLayout $root
397
+ if ($null -eq $layout) { continue }
398
+ if (-not [string]::Equals(
399
+ (Resolve-FullPath $executable),
400
+ $layout.Node,
401
+ [System.StringComparison]::OrdinalIgnoreCase
402
+ )) { continue }
403
+ $commandLine = [string] $process.CommandLine
404
+ if ($commandLine -match '(?i)@deepseek-ai[\\/]dsh[\\/]lib[\\/]bin\.js') {
405
+ Write-Output $layout
406
+ }
407
+ }
408
+ } catch {
409
+ # Process discovery is only one optional location hint.
410
+ }
411
+ return $null
412
+ }
413
+
414
+ function Find-CommonPortables {
415
+ $candidates = New-Object System.Collections.Generic.List[string]
416
+ if ($env:LOCALAPPDATA) {
417
+ $candidates.Add((Join-Path $env:LOCALAPPDATA 'Programs\DeepSeek-Herness'))
418
+ }
419
+ if ($env:USERPROFILE) {
420
+ $candidates.Add((Join-Path $env:USERPROFILE 'Downloads\DSH-Portable'))
421
+ $candidates.Add((Join-Path $env:USERPROFILE 'Desktop\DSH-Portable'))
422
+ foreach ($parent in @(
423
+ (Join-Path $env:USERPROFILE 'Downloads'),
424
+ (Join-Path $env:USERPROFILE 'Desktop')
425
+ )) {
426
+ if (Test-Path -LiteralPath $parent -PathType Container) {
427
+ foreach ($directory in Get-ChildItem -LiteralPath $parent -Directory -ErrorAction SilentlyContinue) {
428
+ $candidates.Add($directory.FullName)
429
+ }
430
+ }
431
+ }
432
+ }
433
+
434
+ foreach ($candidate in $candidates | Select-Object -Unique) {
435
+ $layout = Get-PortableLayout $candidate
436
+ if ($null -ne $layout) { Write-Output $layout }
437
+ }
438
+ }
439
+
440
+ function Get-ManagerTarget {
441
+ if ($PortableRoot) {
442
+ $layout = Get-PortableLayout $PortableRoot
443
+ if ($null -eq $layout) {
444
+ throw "The selected DSH-Portable folder is incomplete: $PortableRoot"
445
+ }
446
+ return New-PortableTarget $layout
447
+ }
448
+
449
+ $layout = Find-PortableFromCurrentDirectory
450
+ if ($null -ne $layout) {
451
+ return New-PortableTarget $layout
452
+ }
453
+
454
+ $runningLayouts = @(Find-RunningPortables | Sort-Object -Property Root -Unique)
455
+ if ($runningLayouts.Count -gt 1) {
456
+ $paths = ($runningLayouts | ForEach-Object { "- $($_.Root)" }) -join [Environment]::NewLine
457
+ throw "More than one running DSH-Portable was found. Re-run with -PortableRoot:`n$paths"
458
+ }
459
+ if ($runningLayouts.Count -eq 1) {
460
+ $layout = $runningLayouts[0]
461
+ return New-PortableTarget $layout
462
+ }
463
+
464
+ $globalDsh = Get-Command dsh -ErrorAction SilentlyContinue
465
+ $commonLayouts = @(Find-CommonPortables | Sort-Object -Property Root -Unique)
466
+ if ($commonLayouts.Count -gt 1) {
467
+ $paths = ($commonLayouts | ForEach-Object { "- $($_.Root)" }) -join [Environment]::NewLine
468
+ throw "More than one DSH-Portable folder was found. Re-run with -PortableRoot:`n$paths"
469
+ }
470
+ if ($null -ne $globalDsh -and $commonLayouts.Count -eq 1) {
471
+ throw "Both a global dsh command and DSH-Portable were found. Run from the intended portable folder, or pass -PortableRoot '$($commonLayouts[0].Root)'."
472
+ }
473
+ if ($commonLayouts.Count -eq 1) {
474
+ $layout = $commonLayouts[0]
475
+ return New-PortableTarget $layout
476
+ }
477
+ if ($null -ne $globalDsh) {
478
+ $globalNode = Get-Command node -ErrorAction SilentlyContinue
479
+ if ($null -eq $globalNode) { throw 'The dsh command exists, but Node.js is not available on PATH.' }
480
+ return [pscustomobject]@{ Mode = 'global'; Layout = $null; Executable = $globalDsh.Source; Node = $globalNode.Source; UsesPortableCli = $false }
481
+ }
482
+
483
+ throw @'
484
+ DeepSeek Harness was not found.
485
+
486
+ If you use DSH-Portable, start it once or run this command from inside its
487
+ folder. You can also pass -PortableRoot "C:\path\to\DSH-Portable".
488
+ '@
489
+ }
490
+
491
+ function Get-PnpmDirectory {
492
+ param([Parameter(Mandatory = $true)] $Target)
493
+ if ($Target.UsesPortableCli) { return $null }
494
+ if ($Target.Mode -eq 'portable') {
495
+ return Join-Path $Target.Layout.StateRoot "data\runtime\dsh-codex-tools\pnpm-$PnpmVersion"
496
+ }
497
+ if (-not $env:LOCALAPPDATA) { throw 'LOCALAPPDATA is required to cache the plugin manager.' }
498
+ return Join-Path $env:LOCALAPPDATA "dsh-codex-subscription\tools\pnpm-$PnpmVersion"
499
+ }
500
+
501
+ function Get-PnpmStore {
502
+ param([Parameter(Mandatory = $true)] $Target)
503
+ if ($Target.Mode -eq 'portable') {
504
+ return Join-Path $Target.Layout.StateRoot 'data\pnpm-store'
505
+ }
506
+ return $null
507
+ }
508
+
509
+ function Test-PnpmDirectory {
510
+ param(
511
+ [Parameter(Mandatory = $true)][string] $Directory,
512
+ [Parameter(Mandatory = $true)][string] $Node
513
+ )
514
+ $entry = Join-Path $Directory 'package\bin\pnpm.cjs'
515
+ if (-not (Test-Path -LiteralPath $entry -PathType Leaf)) { return $false }
516
+ try {
517
+ $reported = (& $Node $entry '--version' 2>$null | Select-Object -First 1)
518
+ return ([string] $reported).Trim() -eq $PnpmVersion
519
+ } catch {
520
+ return $false
521
+ }
522
+ }
523
+
524
+ function Install-PnpmTool {
525
+ param(
526
+ [Parameter(Mandatory = $true)][string] $Directory,
527
+ [Parameter(Mandatory = $true)][string] $Node
528
+ )
529
+
530
+ if (Test-PnpmDirectory -Directory $Directory -Node $Node) { return }
531
+
532
+ $parent = Split-Path -Parent $Directory
533
+ New-Item -ItemType Directory -Force -Path $parent | Out-Null
534
+ $stage = Join-Path $parent ('.pnpm-' + [guid]::NewGuid().ToString('N'))
535
+ $archive = Join-Path $stage 'pnpm.tgz'
536
+ New-Item -ItemType Directory -Path $stage | Out-Null
537
+ try {
538
+ Write-Host "Preparing the bundled plugin manager (pnpm $PnpmVersion)..."
539
+ Invoke-WebRequest -UseBasicParsing -Uri $PnpmUrl -OutFile $archive
540
+ $actualHash = Get-FileDigest -Algorithm SHA512 -Path $archive
541
+ if ($actualHash -ne $PnpmSha512) {
542
+ throw "pnpm download checksum mismatch. Expected $PnpmSha512, received $actualHash."
543
+ }
544
+ $tar = Get-Command tar.exe -ErrorAction SilentlyContinue
545
+ if ($null -eq $tar) { throw 'Windows tar.exe is required to unpack the verified pnpm package.' }
546
+ & $tar.Source '-xzf' $archive '-C' $stage
547
+ if ($LASTEXITCODE -ne 0) { throw "tar.exe failed with exit code $LASTEXITCODE." }
548
+ Remove-Item -LiteralPath $archive -Force
549
+ if (-not (Test-PnpmDirectory -Directory $stage -Node $Node)) {
550
+ throw "The extracted pnpm package did not report version $PnpmVersion."
551
+ }
552
+ $shim = "@echo off`r`n`"%DSH_CODEX_NODE%`" `"%~dp0package\bin\pnpm.cjs`" %*`r`n"
553
+ [System.IO.File]::WriteAllText((Join-Path $stage 'pnpm.cmd'), $shim, [System.Text.Encoding]::ASCII)
554
+ if (Test-Path -LiteralPath $Directory) {
555
+ Remove-Item -LiteralPath $Directory -Recurse -Force
556
+ }
557
+ Move-Item -LiteralPath $stage -Destination $Directory
558
+ } finally {
559
+ if (Test-Path -LiteralPath $stage) {
560
+ Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue
561
+ }
562
+ }
563
+ }
564
+
565
+ function Get-ActionArguments {
566
+ param(
567
+ [Parameter(Mandatory = $true)][string] $SelectedAction,
568
+ [AllowNull()][string] $Store,
569
+ [string] $SelectedPackage = $PackageName
570
+ )
571
+ if ($SelectedAction -eq 'Uninstall') {
572
+ $arguments = @('plugin', '--profile', $Profile, 'remove', $SelectedPackage)
573
+ } else {
574
+ $arguments = @('plugin', '--profile', $Profile, 'add', $PackageSpec)
575
+ }
576
+ if ($Store) { $arguments += @('--store-dir', $Store) }
577
+ $arguments += @('--loglevel', 'error')
578
+ return $arguments
579
+ }
580
+
581
+ function Invoke-DshCommand {
582
+ param(
583
+ [Parameter(Mandatory = $true)] $Target,
584
+ [Parameter(Mandatory = $true)][string[]] $Arguments,
585
+ [switch] $Capture
586
+ )
587
+ $allArguments = if ($Target.Mode -eq 'portable' -and -not $Target.UsesPortableCli) {
588
+ @($Target.Layout.Dsh) + $Arguments
589
+ } else {
590
+ $Arguments
591
+ }
592
+ if ($Capture) {
593
+ $output = & $Target.Executable @allArguments 2>&1
594
+ $exitCode = $LASTEXITCODE
595
+ if ($exitCode -ne 0) { throw "dsh failed with exit code $exitCode.`n$($output -join [Environment]::NewLine)" }
596
+ return ($output -join [Environment]::NewLine)
597
+ }
598
+ & $Target.Executable @allArguments
599
+ if ($LASTEXITCODE -ne 0) { throw "dsh failed with exit code $LASTEXITCODE." }
600
+ }
601
+
602
+ function Get-InstalledPackageNames {
603
+ param([Parameter(Mandatory = $true)] $Target)
604
+
605
+ $json = Invoke-DshCommand -Target $Target -Arguments @(
606
+ 'plugin', '--profile', $Profile, 'list', '--depth', '0', '--json', '--loglevel', 'error'
607
+ ) -Capture
608
+ try {
609
+ # Windows PowerShell 5.1 preserves a top-level JSON array as one pipeline
610
+ # object. Assign first so @() expands the resulting Object[] correctly.
611
+ $parsedProjects = $json | ConvertFrom-Json
612
+ $projects = @($parsedProjects)
613
+ } catch {
614
+ throw 'DSH returned an unreadable plugin list.'
615
+ }
616
+
617
+ foreach ($project in $projects) {
618
+ $dependenciesProperty = $project.PSObject.Properties['dependencies']
619
+ if ($null -eq $dependenciesProperty -or $null -eq $dependenciesProperty.Value) { continue }
620
+ foreach ($property in $dependenciesProperty.Value.PSObject.Properties) {
621
+ Write-Output ([string] $property.Name)
622
+ }
623
+ }
624
+ }
625
+
626
+ $managerCommandRoot = Get-ManagerCommandRoot
627
+ if ($Managed -and $Action -eq 'Update' -and -not $SkipSelfUpdate -and -not $DryRun) {
628
+ Invoke-LatestManager -InstalledCommandRoot $managerCommandRoot
629
+ exit 0
630
+ }
631
+
632
+ $target = Get-ManagerTarget
633
+ $managerCommand = Join-Path $managerCommandRoot 'dsh-codex.cmd'
634
+ $pnpmDirectory = Get-PnpmDirectory $target
635
+ $pnpmStore = Get-PnpmStore $target
636
+ $actionArguments = Get-ActionArguments -SelectedAction $Action -Store $pnpmStore
637
+ $arguments = if ($target.Mode -eq 'portable' -and -not $target.UsesPortableCli) {
638
+ @($target.Layout.Dsh) + $actionArguments
639
+ } else {
640
+ $actionArguments
641
+ }
642
+
643
+ if ($DryRun) {
644
+ [ordered]@{
645
+ mode = $target.Mode
646
+ action = $Action
647
+ executable = $target.Executable
648
+ arguments = $arguments
649
+ dshHome = if ($target.Mode -eq 'portable') { $target.Layout.DshHome } else { $null }
650
+ pnpmVersion = $PnpmVersion
651
+ pnpmDirectory = $pnpmDirectory
652
+ pnpmStore = $pnpmStore
653
+ packageName = $PackageName
654
+ legacyPackageName = $LegacyPackageName
655
+ packageVersion = $PackageVersion
656
+ packageSpec = $PackageSpec
657
+ managerCommand = $managerCommand
658
+ installsManagerCommand = $Action -ne 'Uninstall'
659
+ modifiesUserPath = ($Action -ne 'Uninstall') -and (-not $NoModifyPath)
660
+ removesProfile = $false
661
+ } | ConvertTo-Json -Depth 4 -Compress
662
+ exit 0
663
+ }
664
+
665
+ $oldPath = $env:PATH
666
+ $oldDshHome = $env:DSH_HOME
667
+ $oldDshPortable = $env:DSH_PORTABLE
668
+ $oldTelemetry = $env:DSH_TELEMETRY_MODE
669
+ $oldManagerNode = $env:DSH_CODEX_NODE
670
+ $oldPnpmStore = $env:npm_config_store_dir
671
+ $oldPnpmNotifier = $env:npm_config_update_notifier
672
+ try {
673
+ if (-not $target.UsesPortableCli) {
674
+ Install-PnpmTool -Directory $pnpmDirectory -Node $target.Node
675
+ }
676
+ $env:DSH_CODEX_NODE = $target.Node
677
+ if (-not $target.UsesPortableCli) {
678
+ $env:PATH = $pnpmDirectory + [System.IO.Path]::PathSeparator + (Split-Path -Parent $target.Node) + [System.IO.Path]::PathSeparator + $oldPath
679
+ }
680
+ $env:npm_config_update_notifier = 'false'
681
+ if ($target.Mode -eq 'portable') {
682
+ New-Item -ItemType Directory -Force -Path $target.Layout.DshHome | Out-Null
683
+ $env:DSH_HOME = $target.Layout.DshHome
684
+ $env:DSH_PORTABLE = '1'
685
+ $env:DSH_TELEMETRY_MODE = 'DISABLED'
686
+ $env:npm_config_store_dir = $pnpmStore
687
+ }
688
+
689
+ $installedBefore = @(Get-InstalledPackageNames -Target $target)
690
+ $hadPackage = $installedBefore -contains $PackageName
691
+ $hadLegacyPackage = $installedBefore -contains $LegacyPackageName
692
+
693
+ if ($Action -eq 'Uninstall') {
694
+ if ($hadPackage) {
695
+ Invoke-DshCommand -Target $target -Arguments (
696
+ Get-ActionArguments -SelectedAction 'Uninstall' -Store $pnpmStore -SelectedPackage $PackageName
697
+ )
698
+ }
699
+ if ($hadLegacyPackage) {
700
+ Invoke-DshCommand -Target $target -Arguments (
701
+ Get-ActionArguments -SelectedAction 'Uninstall' -Store $pnpmStore -SelectedPackage $LegacyPackageName
702
+ )
703
+ }
704
+ } else {
705
+ Invoke-DshCommand -Target $target -Arguments $actionArguments
706
+ if ($hadLegacyPackage) {
707
+ try {
708
+ Invoke-DshCommand -Target $target -Arguments (
709
+ Get-ActionArguments -SelectedAction 'Uninstall' -Store $pnpmStore -SelectedPackage $LegacyPackageName
710
+ )
711
+ } catch {
712
+ if (-not $hadPackage) {
713
+ try {
714
+ Invoke-DshCommand -Target $target -Arguments (
715
+ Get-ActionArguments -SelectedAction 'Uninstall' -Store $pnpmStore -SelectedPackage $PackageName
716
+ )
717
+ } catch {
718
+ # Preserve the original migration error.
719
+ }
720
+ }
721
+ throw
722
+ }
723
+ }
724
+ }
725
+
726
+ $config = Invoke-DshCommand -Target $target -Arguments @('--profile', $Profile, '--dump-config') -Capture
727
+ $entryCount = ([regex]::Matches($config, '(?<![A-Za-z0-9_-])codex-subscription(?![A-Za-z0-9_-])')).Count
728
+ $legacyEntryCount = ([regex]::Matches($config, '(?<![A-Za-z0-9_-])wsl043-codex-subscription(?![A-Za-z0-9_-])')).Count
729
+ $installedAfter = @(Get-InstalledPackageNames -Target $target)
730
+ if ($Action -eq 'Uninstall') {
731
+ if (($installedAfter -contains $PackageName) -or ($installedAfter -contains $LegacyPackageName)) {
732
+ throw 'The plugin package is still present after uninstall.'
733
+ }
734
+ if ($entryCount -ne 0 -or $legacyEntryCount -ne 0) {
735
+ throw 'The plugin package was removed, but its profile entry is still present.'
736
+ }
737
+ Remove-ManagerCommand -Directory $managerCommandRoot
738
+ Write-Host 'Uninstalled. The DSH profile and saved credentials were kept.'
739
+ } else {
740
+ if (-not ($installedAfter -contains $PackageName)) { throw 'The installed package did not appear in the DSH plugin list.' }
741
+ if ($installedAfter -contains $LegacyPackageName) { throw 'The legacy package is still present after migration.' }
742
+ if ($entryCount -ne 1) { throw "Expected one plugin profile entry, found $entryCount." }
743
+ if ($legacyEntryCount -ne 0) { throw 'The legacy plugin profile entry is still present after migration.' }
744
+ Install-ManagerCommand -Directory $managerCommandRoot
745
+ $verb = if ($Action -eq 'Update') { 'Updated.' } else { 'Installed.' }
746
+ Write-Host "$verb Restart DSH manually to load the change if it is currently running."
747
+ }
748
+ } finally {
749
+ $env:PATH = $oldPath
750
+ $env:DSH_HOME = $oldDshHome
751
+ $env:DSH_PORTABLE = $oldDshPortable
752
+ $env:DSH_TELEMETRY_MODE = $oldTelemetry
753
+ $env:DSH_CODEX_NODE = $oldManagerNode
754
+ $env:npm_config_store_dir = $oldPnpmStore
755
+ $env:npm_config_update_notifier = $oldPnpmNotifier
756
+ }