ever-works-node 0.2.1 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ever-works-node",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Ever Works Node - headless execution node (CLI + long-running service): fleet enrollment, heartbeat and capability reporting",
5
5
  "author": {
6
6
  "name": "Ever Co. LTD",
@@ -51,6 +51,10 @@ longest expected job.
51
51
  `systemctl reload` is wired to `ever-works-node pause` — a drain, not a
52
52
  restart.
53
53
 
54
+ Logs go to the journal (`StandardOutput=journal`), which journald already
55
+ rotates and bounds (`SystemMaxUse=` in `journald.conf`), so the unit needs
56
+ nothing of its own.
57
+
54
58
  ## Windows
55
59
 
56
60
  Enroll first, **as the account the node will run as**, then install from an
@@ -194,6 +198,21 @@ acceptable there only because `Stop-ScheduledTask` terminates the task
194
198
  outright and delivers no console event for `cmd.exe` to swallow — that branch
195
199
  never drained.
196
200
 
201
+ Both logs **rotate past 10 MB** (one policy, `Get-NodeLogRotationPolicy`). The
202
+ service rotates them with NSSM's own `AppRotateFiles` / `AppRotateOnline` /
203
+ `AppRotateBytes` — at start and online while it runs. NSSM never deletes a
204
+ rotated file, so the service install also registers `<Name>-LogPrune`, a
205
+ small `SYSTEM` scheduled task that runs daily at 04:30 and at boot and keeps
206
+ the newest five rotated generations of each log (NSSM's `node-<timestamp>.log`
207
+ names and this installer's own); `uninstall-service.ps1` removes it with the
208
+ service. The task cannot rotate while the node runs (`cmd.exe` holds
209
+ the file open without delete sharing, and truncating under its write offset
210
+ would leave a hole), so it rotates when the task STARTS — at boot and on every
211
+ restart-on-failure — as a first action that runs to completion before the
212
+ node action, renaming a full log to `<name>.<yyyyMMddTHHmmss>` and keeping
213
+ the newest five. Re-run the installer to add rotation to a machine
214
+ installed before it existed.
215
+
197
216
  Without `-ServicePassword` the task falls back to an **S4U** logon, which is
198
217
  minted without the account's password and therefore never unlocks its DPAPI
199
218
  master key: Windows Credential Manager reads fail there exactly as they do
@@ -462,6 +462,35 @@ function Test-PathUnder {
462
462
  secret; the step is FLAGGED RequiresPassword and the plan stays safe to
463
463
  print and to assert on.
464
464
  #>
465
+ <#
466
+ .SYNOPSIS
467
+ How big node.log / node.err.log may grow before they are rotated, and
468
+ how many rotated generations the scheduled-task branch keeps.
469
+
470
+ .DESCRIPTION
471
+ Self-build slice AP. Both mechanisms used to append to the same two
472
+ files forever: the NSSM service had no AppRotate* settings at all, and
473
+ the scheduled task redirected through cmd.exe with nothing ever
474
+ trimming the target. On a node that runs for months that is the log
475
+ eating the very disk the workspace floor guards.
476
+
477
+ One policy, read by both branches, so the two cannot drift: 10 MB per
478
+ file. The service rotates with NSSM's own AppRotateFiles /
479
+ AppRotateOnline / AppRotateBytes (online, while running); NSSM never
480
+ deletes a rotated file, so the service branch also registers a daily
481
+ prune task that keeps the newest Keep generations (New-LogPruneScript).
482
+ The scheduled task rotates when it STARTS (boot, and every
483
+ restart-on-failure) and keeps the newest Keep generations - see
484
+ New-ScheduledTaskRotationActionSpec for why it cannot rotate while
485
+ running.
486
+ #>
487
+ function Get-NodeLogRotationPolicy {
488
+ return [pscustomobject]@{
489
+ Bytes = 10485760
490
+ Keep = 5
491
+ }
492
+ }
493
+
465
494
  function New-ServiceConfigurationPlan {
466
495
  param(
467
496
  [Parameter(Mandatory = $true)][string] $Name,
@@ -503,6 +532,14 @@ function New-ServiceConfigurationPlan {
503
532
 
504
533
  & $addNssm @('set', $Name, 'AppStdout', (Join-Path $StateDir 'node.log')) 'redirect stdout'
505
534
  & $addNssm @('set', $Name, 'AppStderr', (Join-Path $StateDir 'node.err.log')) 'redirect stderr'
535
+ # Self-build slice AP: rotate both logs - when the service starts, and
536
+ # ONLINE while it runs - past the shared size policy. Re-applied on every
537
+ # run like every other setting, so re-running the installer converges a
538
+ # service installed before rotation existed.
539
+ $rotation = Get-NodeLogRotationPolicy
540
+ & $addNssm @('set', $Name, 'AppRotateFiles', '1') 'rotate the logs when the service starts'
541
+ & $addNssm @('set', $Name, 'AppRotateOnline', '1') 'rotate the logs while the service runs'
542
+ & $addNssm @('set', $Name, 'AppRotateBytes', [string]$rotation.Bytes) "rotate a log once it passes $($rotation.Bytes) bytes"
506
543
  & $addNssm @('set', $Name, 'AppStopMethodConsole', '900000') 'give the drain 15 minutes'
507
544
  & $addNssm @('set', $Name, 'AppExit', 'Default', 'Restart') 'restart on exit'
508
545
  & $addNssm @('set', $Name, 'AppRestartDelay', '10000') 'back off 10s between restarts'
@@ -685,6 +722,172 @@ function New-ScheduledTaskActionSpec {
685
722
  }
686
723
  }
687
724
 
725
+ <#
726
+ .SYNOPSIS
727
+ The PowerShell that rotates node.log / node.err.log, as text.
728
+
729
+ .DESCRIPTION
730
+ Self-build slice AP. A log at or past RotateBytes is renamed to
731
+ <name>.<yyyyMMddTHHmmss> - a timestamp, like NSSM's own rotated names -
732
+ and only the newest Keep rotated generations of that log are kept. A missing log, a locked log or any
733
+ other failure is swallowed and the script exits 0: a rotation problem
734
+ must never stop the node from starting.
735
+
736
+ Paths are embedded as single-quoted literals with every quote doubled,
737
+ which is PowerShell's only escape inside single quotes - no expansion of
738
+ $, backtick or anything else can happen to them.
739
+ #>
740
+ function New-LogRotationScript {
741
+ param(
742
+ [Parameter(Mandatory = $true)][string[]] $LogPaths,
743
+ [Parameter(Mandatory = $true)][long] $RotateBytes,
744
+ [Parameter(Mandatory = $true)][int] $Keep
745
+ )
746
+ $quoted = @($LogPaths | ForEach-Object { "'" + ($_ -replace "'", "''") + "'" }) -join ', '
747
+ $lines = @(
748
+ '$ErrorActionPreference = ''Stop''',
749
+ "foreach (`$log in @($quoted)) {",
750
+ ' try {',
751
+ ' $item = Get-Item -LiteralPath $log -ErrorAction SilentlyContinue',
752
+ " if (`$null -eq `$item -or `$item.Length -lt $RotateBytes) { continue }",
753
+ ' $stamp = (Get-Date).ToString(''yyyyMMddTHHmmss'')',
754
+ ' Move-Item -LiteralPath $log -Destination ($log + ''.'' + $stamp) -Force'
755
+ ) + (Get-LogPruneScriptLines -Keep $Keep) + @(
756
+ ' }',
757
+ ' catch { }',
758
+ '}',
759
+ 'exit 0'
760
+ )
761
+ return ($lines -join "`r`n")
762
+ }
763
+
764
+ <#
765
+ .SYNOPSIS
766
+ The lines that delete all but the newest Keep rotated generations of
767
+ the log in $log, as script text for the generated scripts.
768
+
769
+ .DESCRIPTION
770
+ Self-build slice AP (review). Matches BOTH naming schemes a rotated log
771
+ can have in the state directory, and nothing else:
772
+
773
+ * <leaf>.<yyyyMMddTHHmmss> - this installer's task branch
774
+ (node.log.20261008T034500);
775
+ * <base>-<yyyyMMddTHHmmss>[.fff]<ext> - NSSM's own AppRotate names
776
+ (node-20261008T034500.123.log,
777
+ node.err-20261008T034500.123.log).
778
+
779
+ Ordered by the timestamp IN THE NAME, newest first, not by
780
+ LastWriteTime: a rename keeps the file's old modification time, so the
781
+ generation just rotated would otherwise sort as the oldest and be the
782
+ one deleted. The live log itself never matches either pattern.
783
+ #>
784
+ function Get-LogPruneScriptLines {
785
+ param([Parameter(Mandatory = $true)][int] $Keep)
786
+ return @(
787
+ ' $leaf = Split-Path -Leaf $log',
788
+ ' $base = [IO.Path]::GetFileNameWithoutExtension($leaf)',
789
+ ' $ext = [IO.Path]::GetExtension($leaf)',
790
+ ' $pattern = ''^(?:'' + [regex]::Escape($leaf) + ''\.\d{8}T\d{6}|'' + [regex]::Escape($base) + ''-\d{8}T\d{6}(?:\.\d{3})?'' + [regex]::Escape($ext) + '')$''',
791
+ ' $rotated = @(Get-ChildItem -LiteralPath (Split-Path -Parent $log) -File | Where-Object { $_.Name -match $pattern } | Sort-Object { [regex]::Match($_.Name, ''\d{8}T\d{6}'').Value } -Descending)',
792
+ " if (`$rotated.Count -gt $Keep) { `$rotated | Select-Object -Skip $Keep | Remove-Item -Force }"
793
+ )
794
+ }
795
+
796
+ <#
797
+ .SYNOPSIS
798
+ The PowerShell that ONLY prunes old rotated generations of the logs
799
+ (it never rotates the live file), as text.
800
+
801
+ .DESCRIPTION
802
+ Self-build slice AP (review). NSSM's AppRotate* settings rotate the
803
+ service's logs but never delete a rotated generation, so on a node that
804
+ runs for months they accumulate without bound - the disk the workspace
805
+ floor guards. The service branch therefore also registers a small
806
+ scheduled task (New-ScheduledTaskPruneActionSpec) that runs this daily
807
+ and at boot, keeping the newest Keep generations of each log, the same
808
+ number the scheduled-task branch keeps. Every failure is swallowed and
809
+ the script exits 0.
810
+ #>
811
+ function New-LogPruneScript {
812
+ param(
813
+ [Parameter(Mandatory = $true)][string[]] $LogPaths,
814
+ [Parameter(Mandatory = $true)][int] $Keep
815
+ )
816
+ $quoted = @($LogPaths | ForEach-Object { "'" + ($_ -replace "'", "''") + "'" }) -join ', '
817
+ $lines = @(
818
+ '$ErrorActionPreference = ''Stop''',
819
+ "foreach (`$log in @($quoted)) {",
820
+ ' try {'
821
+ ) + (Get-LogPruneScriptLines -Keep $Keep) + @(
822
+ ' }',
823
+ ' catch { }',
824
+ '}',
825
+ 'exit 0'
826
+ )
827
+ return ($lines -join "`r`n")
828
+ }
829
+
830
+ <#
831
+ .SYNOPSIS
832
+ The service branch's log-prune task action (self-build slice AP review).
833
+
834
+ .DESCRIPTION
835
+ Same encoding as New-ScheduledTaskRotationActionSpec: -EncodedCommand,
836
+ so no path or quote in the script survives a second round of
837
+ command-line parsing.
838
+ #>
839
+ function New-ScheduledTaskPruneActionSpec {
840
+ param(
841
+ [Parameter(Mandatory = $true)][string] $PowerShellExe,
842
+ [Parameter(Mandatory = $true)][string[]] $LogPaths,
843
+ [Parameter(Mandatory = $true)][int] $Keep
844
+ )
845
+ $script = New-LogPruneScript -LogPaths $LogPaths -Keep $Keep
846
+ $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($script))
847
+ return [pscustomobject]@{
848
+ Execute = $PowerShellExe
849
+ Argument = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encoded"
850
+ Script = $script
851
+ }
852
+ }
853
+
854
+ <#
855
+ .SYNOPSIS
856
+ The scheduled task's FIRST action: rotate the logs, then let the node
857
+ action start.
858
+
859
+ .DESCRIPTION
860
+ Self-build slice AP. The task branch writes through cmd.exe's >> (see
861
+ New-ScheduledTaskActionSpec), and a file cmd.exe holds open can be
862
+ neither renamed (no FILE_SHARE_DELETE) nor safely truncated (cmd keeps
863
+ its own write offset and would leave a hole of NULs). So this branch
864
+ rotates when the task STARTS - at boot and on every restart-on-failure -
865
+ as a separate action that runs to completion before the node action:
866
+ the Task Scheduler runs a task's actions one after another, in order.
867
+ Between starts the log still grows; NSSM's online rotation is one more
868
+ reason the service stays the recommended shape.
869
+
870
+ -EncodedCommand (base64 of the UTF-16LE script) rather than -Command so
871
+ no path, quote or $ in the script has to survive a second round of
872
+ command-line parsing. Windows PowerShell 5.1 is assumed and is in-box on
873
+ every supported Windows.
874
+ #>
875
+ function New-ScheduledTaskRotationActionSpec {
876
+ param(
877
+ [Parameter(Mandatory = $true)][string] $PowerShellExe,
878
+ [Parameter(Mandatory = $true)][string[]] $LogPaths,
879
+ [Parameter(Mandatory = $true)][long] $RotateBytes,
880
+ [Parameter(Mandatory = $true)][int] $Keep
881
+ )
882
+ $script = New-LogRotationScript -LogPaths $LogPaths -RotateBytes $RotateBytes -Keep $Keep
883
+ $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($script))
884
+ return [pscustomobject]@{
885
+ Execute = $PowerShellExe
886
+ Argument = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encoded"
887
+ Script = $script
888
+ }
889
+ }
890
+
688
891
  # --- Preflight ------------------------------------------------------------
689
892
 
690
893
  function New-PreflightFinding {
@@ -825,6 +825,35 @@ if ($mechanism -eq 'service') {
825
825
  throw "$reason The service '$Name' was created by this run and has been removed again, so this machine is as it was found. A wrong -ServicePassword shows up here as SCM error 1069."
826
826
  }
827
827
 
828
+ # Self-build slice AP (review): NSSM rotates the logs but never deletes a
829
+ # rotated generation. A small SYSTEM task prunes them, daily and at boot,
830
+ # to the same number the scheduled-task branch keeps. Registered with
831
+ # -Force so a re-run converges it. A failure here WARNS rather than
832
+ # undoing the service: rotation still bounds each file, and an operator
833
+ # can re-run the installer once the cause is fixed.
834
+ $pruneTaskName = "$Name-LogPrune"
835
+ $pruneKeep = (Get-NodeLogRotationPolicy).Keep
836
+ $pruneSpec = New-ScheduledTaskPruneActionSpec `
837
+ -PowerShellExe (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') `
838
+ -LogPaths @($stdoutLog, $stderrLog) `
839
+ -Keep $pruneKeep
840
+ if ($commitChanges) {
841
+ try {
842
+ $pruneAction = New-ScheduledTaskAction -Execute $pruneSpec.Execute -Argument $pruneSpec.Argument -WorkingDirectory $stateDir
843
+ $pruneTriggers = @((New-ScheduledTaskTrigger -Daily -At '04:30'), (New-ScheduledTaskTrigger -AtStartup))
844
+ $prunePrincipal = New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' -LogonType ServiceAccount -RunLevel Highest
845
+ $pruneSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 10)
846
+ Register-ScheduledTask -TaskName $pruneTaskName -Action $pruneAction -Trigger $pruneTriggers -Principal $prunePrincipal -Settings $pruneSettings -Force | Out-Null
847
+ Write-Host "Registered scheduled task '$pruneTaskName' (keeps the newest $pruneKeep rotated generations of each log)."
848
+ }
849
+ catch {
850
+ Write-Warning "Could not register the log-prune task '$pruneTaskName': $($_.Exception.Message). NSSM still rotates the logs, but rotated generations will accumulate until the installer is re-run."
851
+ }
852
+ }
853
+ else {
854
+ Write-Host " Register-ScheduledTask -TaskName $pruneTaskName -Action <powershell -EncodedCommand: keep the newest $pruneKeep rotated logs> -Trigger <daily 04:30, atStartup> -Principal SYSTEM -Force"
855
+ }
856
+
828
857
  Write-Host " Application: $nodeExe"
829
858
  Write-Host " AppParameters: $argumentLine"
830
859
  Write-Host " RunAs: $($identity.ServiceLogonName)"
@@ -854,7 +883,18 @@ else {
854
883
  -StdoutPath $stdoutLog `
855
884
  -StderrPath $stderrLog
856
885
 
857
- $action = New-ScheduledTaskAction -Execute $actionSpec.Execute -Argument $actionSpec.Argument -WorkingDirectory $stateDir
886
+ # Self-build slice AP: the logs rotate first, as their own action - see
887
+ # New-ScheduledTaskRotationActionSpec for why this branch cannot rotate
888
+ # while the node runs.
889
+ $rotationPolicy = Get-NodeLogRotationPolicy
890
+ $rotationSpec = New-ScheduledTaskRotationActionSpec `
891
+ -PowerShellExe (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') `
892
+ -LogPaths @($stdoutLog, $stderrLog) `
893
+ -RotateBytes $rotationPolicy.Bytes `
894
+ -Keep $rotationPolicy.Keep
895
+ $rotationAction = New-ScheduledTaskAction -Execute $rotationSpec.Execute -Argument $rotationSpec.Argument -WorkingDirectory $stateDir
896
+ $nodeAction = New-ScheduledTaskAction -Execute $actionSpec.Execute -Argument $actionSpec.Argument -WorkingDirectory $stateDir
897
+ $action = @($rotationAction, $nodeAction)
858
898
  $trigger = New-ScheduledTaskTrigger -AtStartup
859
899
  $settings = New-ScheduledTaskSettingsSet `
860
900
  -AllowStartIfOnBatteries `
@@ -924,6 +964,7 @@ else {
924
964
  Write-Host " Start-ScheduledTask -TaskName $Name"
925
965
  }
926
966
 
967
+ Write-Host " Rotate: $($rotationSpec.Execute) -EncodedCommand <logs past $($rotationPolicy.Bytes) bytes, keep $($rotationPolicy.Keep)>"
927
968
  Write-Host " Execute: $($actionSpec.Execute)"
928
969
  Write-Host " Argument: $($actionSpec.Argument)"
929
970
  Write-Host " RunAs: $($identity.TaskUserId) (LogonType $($taskPlan.LogonType))"
@@ -931,7 +972,7 @@ else {
931
972
  }
932
973
 
933
974
  Write-Host ''
934
- Write-Host "Logs: $stdoutLog and $stderrLog"
975
+ Write-Host "Logs: $stdoutLog and $stderrLog (rotated past $((Get-NodeLogRotationPolicy).Bytes) bytes)"
935
976
  Write-Host 'Operating:'
936
977
  Write-Host ' drain (finish in-flight work, take no more): ever-works-node pause'
937
978
  Write-Host ' take work again: ever-works-node resume'
@@ -452,6 +452,23 @@ foreach ($required in @('Application', 'AppParameters', 'AppDirectory', 'Start',
452
452
  # command-line argument, which process-creation telemetry records.
453
453
  Assert-False ($planSettings -contains 'ObjectName') 'the password must not reach nssm as an ObjectName argument'
454
454
 
455
+ # Self-build slice AP: both logs rotate - at start AND online - past the
456
+ # shared policy, on a fresh install and on a re-run over an old service.
457
+ $rotationPolicy = Get-NodeLogRotationPolicy
458
+ Assert-Equal $rotationPolicy.Bytes 10485760 'the rotation threshold is 10 MB'
459
+ foreach ($case in @(
460
+ @{ Label = 'fresh install'; Plan = $freshPlan },
461
+ @{ Label = 're-run over an existing service'; Plan = $existingPlan }
462
+ )) {
463
+ $rotateSettings = @{}
464
+ foreach ($step in @($case.Plan | Where-Object { $_.Kind -eq 'nssm' -and $_.Arguments[0] -eq 'set' -and $_.Arguments[2] -like 'AppRotate*' })) {
465
+ $rotateSettings[$step.Arguments[2]] = $step.Arguments[3]
466
+ }
467
+ Assert-Equal $rotateSettings['AppRotateFiles'] '1' "$($case.Label) must rotate the logs at service start"
468
+ Assert-Equal $rotateSettings['AppRotateOnline'] '1' "$($case.Label) must rotate the logs while the service runs"
469
+ Assert-Equal $rotateSettings['AppRotateBytes'] '10485760' "$($case.Label) must rotate past the shared size policy"
470
+ }
471
+
455
472
  # --- Redaction ------------------------------------------------------------
456
473
 
457
474
  $renderedSecret = Format-ServicePlanStep -NssmPath 'C:\nssm.exe' -Step $identityStep
@@ -538,6 +555,102 @@ $spacedLogSpec = New-ScheduledTaskActionSpec `
538
555
  -StderrPath 'C:\log dir\node.err.log'
539
556
  Assert-True ($spacedLogSpec.Argument -like '*>> "C:\log dir\node.log"*') 'a spaced log path must be quoted for cmd.exe'
540
557
 
558
+ # --- Scheduled-task log rotation (self-build slice AP) ---------------------
559
+
560
+ $rotationSpec = New-ScheduledTaskRotationActionSpec `
561
+ -PowerShellExe 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' `
562
+ -LogPaths @('C:\ProgramData\ever-works-node\node.log', "C:\it's here\node.err.log") `
563
+ -RotateBytes 10485760 `
564
+ -Keep 5
565
+ Assert-Equal $rotationSpec.Execute 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' 'the rotation action runs Windows PowerShell'
566
+ Assert-True ($rotationSpec.Argument.StartsWith('-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ')) 'the rotation script travels encoded, never as a re-parsed command line'
567
+ $encodedScript = $rotationSpec.Argument.Substring($rotationSpec.Argument.LastIndexOf(' ') + 1)
568
+ $decodedScript = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encodedScript))
569
+ Assert-Equal $decodedScript $rotationSpec.Script 'the encoded command must decode to exactly the reviewed script'
570
+ Assert-True ($decodedScript.Contains("'C:\ProgramData\ever-works-node\node.log'")) 'stdout log must be rotated'
571
+ Assert-True ($decodedScript.Contains("'C:\it''s here\node.err.log'")) 'a quote in a log path must be doubled inside its single-quoted literal'
572
+ Assert-True ($decodedScript.Contains('-lt 10485760')) 'the threshold must be the policy bytes'
573
+ Assert-True ($decodedScript.TrimEnd().EndsWith('exit 0')) 'a rotation problem must never stop the node action'
574
+ $parseErrors = $null
575
+ [void][Management.Automation.Language.Parser]::ParseInput($decodedScript, [ref]$null, [ref]$parseErrors)
576
+ Assert-Equal @($parseErrors).Count 0 'the generated rotation script must parse under Windows PowerShell'
577
+
578
+ # Run the generated script for real, against a scratch directory under TEMP
579
+ # (the only thing in this file that touches a disk, and it removes it).
580
+ $rotationDir = Join-Path ([IO.Path]::GetTempPath()) ('ew-rotation-test-' + [Guid]::NewGuid().ToString('N'))
581
+ New-Item -ItemType Directory -Path $rotationDir | Out-Null
582
+ try {
583
+ $bigLog = Join-Path $rotationDir 'node.log'
584
+ $smallLog = Join-Path $rotationDir 'node.err.log'
585
+ Set-Content -LiteralPath $bigLog -Value ('x' * 64) -Encoding Ascii
586
+ Set-Content -LiteralPath $smallLog -Value 'x' -Encoding Ascii
587
+ foreach ($stamp in @('20260101T000000', '20260102T000000', '20260103T000000')) {
588
+ Set-Content -LiteralPath (Join-Path $rotationDir "node.log.$stamp") -Value 'old' -Encoding Ascii
589
+ }
590
+ Set-Content -LiteralPath (Join-Path $rotationDir 'node.log.keep-me') -Value 'not a rotation' -Encoding Ascii
591
+ $smallScript = New-LogRotationScript -LogPaths @($bigLog, $smallLog, (Join-Path $rotationDir 'missing.log')) -RotateBytes 32 -Keep 2
592
+ $psExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
593
+ $encodedSmall = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($smallScript))
594
+ & $psExe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encodedSmall
595
+ Assert-Equal $LASTEXITCODE 0 'the rotation script must exit 0'
596
+ Assert-False (Test-Path -LiteralPath $bigLog) 'a log past the threshold must be rotated away'
597
+ Assert-True (Test-Path -LiteralPath $smallLog) 'a log under the threshold must be left alone'
598
+ $rotatedNames = @(Get-ChildItem -LiteralPath $rotationDir -File | Where-Object { $_.Name -match '^node\.log\.\d{8}T\d{6}$' } | ForEach-Object { $_.Name })
599
+ Assert-Equal $rotatedNames.Count 2 'only the newest Keep rotated generations survive'
600
+ Assert-False ($rotatedNames -contains 'node.log.20260101T000000') 'the oldest generation is the one dropped'
601
+ Assert-True (Test-Path -LiteralPath (Join-Path $rotationDir 'node.log.keep-me')) 'a file that is not a rotation must never be touched'
602
+ }
603
+ finally {
604
+ Remove-Item -LiteralPath $rotationDir -Recurse -Force -ErrorAction SilentlyContinue
605
+ }
606
+
607
+ # --- NSSM log retention (self-build slice AP, review) ----------------------
608
+
609
+ # NSSM rotates but never deletes a rotated generation, so the service branch
610
+ # registers a prune task. Its script must keep the newest Keep generations of
611
+ # EACH log under BOTH naming schemes, and touch nothing else.
612
+ $pruneSpec = New-ScheduledTaskPruneActionSpec `
613
+ -PowerShellExe 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' `
614
+ -LogPaths @('C:\ProgramData\ever-works-node\node.log', 'C:\ProgramData\ever-works-node\node.err.log') `
615
+ -Keep 5
616
+ Assert-True ($pruneSpec.Argument.StartsWith('-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ')) 'the prune script travels encoded'
617
+ $decodedPrune = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($pruneSpec.Argument.Substring($pruneSpec.Argument.LastIndexOf(' ') + 1)))
618
+ Assert-Equal $decodedPrune $pruneSpec.Script 'the encoded prune command must decode to exactly the reviewed script'
619
+ Assert-False ($decodedPrune.Contains('Move-Item')) 'the prune script must never rotate the live log - NSSM owns that'
620
+ Assert-True ($decodedPrune.TrimEnd().EndsWith('exit 0')) 'a prune problem must never surface as a task failure'
621
+ $pruneParseErrors = $null
622
+ [void][Management.Automation.Language.Parser]::ParseInput($decodedPrune, [ref]$null, [ref]$pruneParseErrors)
623
+ Assert-Equal @($pruneParseErrors).Count 0 'the generated prune script must parse under Windows PowerShell'
624
+
625
+ $pruneDir = Join-Path ([IO.Path]::GetTempPath()) ('ew-prune-test-' + [Guid]::NewGuid().ToString('N'))
626
+ New-Item -ItemType Directory -Path $pruneDir | Out-Null
627
+ try {
628
+ $liveLog = Join-Path $pruneDir 'node.log'
629
+ $liveErr = Join-Path $pruneDir 'node.err.log'
630
+ Set-Content -LiteralPath $liveLog -Value 'live' -Encoding Ascii
631
+ Set-Content -LiteralPath $liveErr -Value 'live' -Encoding Ascii
632
+ # NSSM's own names, written NEWEST FIRST so modification time disagrees
633
+ # with the timestamp in the name: the name must win.
634
+ foreach ($stamp in @('20260104T000000.400', '20260103T000000.300', '20260102T000000.200', '20260101T000000.100')) {
635
+ Set-Content -LiteralPath (Join-Path $pruneDir "node-$stamp.log") -Value 'old' -Encoding Ascii
636
+ Set-Content -LiteralPath (Join-Path $pruneDir "node.err-$stamp.log") -Value 'old' -Encoding Ascii
637
+ }
638
+ Set-Content -LiteralPath (Join-Path $pruneDir 'node-notes.log') -Value 'not a rotation' -Encoding Ascii
639
+ $smallPrune = New-LogPruneScript -LogPaths @($liveLog, $liveErr) -Keep 2
640
+ $psExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
641
+ & $psExe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ([Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($smallPrune)))
642
+ Assert-Equal $LASTEXITCODE 0 'the prune script must exit 0'
643
+ $left = @(Get-ChildItem -LiteralPath $pruneDir -File | ForEach-Object { $_.Name } | Sort-Object)
644
+ Assert-True ($left -contains 'node.log') 'the live stdout log is never pruned'
645
+ Assert-True ($left -contains 'node.err.log') 'the live stderr log is never pruned'
646
+ Assert-True ($left -contains 'node-notes.log') 'a file that is not a rotation must never be touched'
647
+ Assert-Equal (@($left | Where-Object { $_ -match '^node-\d{8}T' }) -join ',') 'node-20260103T000000.300.log,node-20260104T000000.400.log' 'NSSM stdout generations: the newest two BY NAME survive'
648
+ Assert-Equal (@($left | Where-Object { $_ -match '^node\.err-\d{8}T' }) -join ',') 'node.err-20260103T000000.300.log,node.err-20260104T000000.400.log' 'NSSM stderr generations: the newest two survive, counted per log'
649
+ }
650
+ finally {
651
+ Remove-Item -LiteralPath $pruneDir -Recurse -Force -ErrorAction SilentlyContinue
652
+ }
653
+
541
654
  # --- Preflight ------------------------------------------------------------
542
655
 
543
656
  Assert-Equal (Invoke-Preflight).Count 0 'a fully healthy service install must produce no findings at all'
@@ -58,6 +58,15 @@ if ($null -ne $task) {
58
58
  $removed = $true
59
59
  }
60
60
 
61
+ # Self-build slice AP (review): the service branch's log-prune task. It is
62
+ # this installer's own artefact and means nothing without the service.
63
+ $pruneTaskName = "$Name-LogPrune"
64
+ $pruneTask = Get-ScheduledTask -TaskName $pruneTaskName -ErrorAction SilentlyContinue
65
+ if ($null -ne $pruneTask) {
66
+ Unregister-ScheduledTask -TaskName $pruneTaskName -Confirm:$false
67
+ Write-Host "Removed scheduled task '$pruneTaskName'."
68
+ }
69
+
61
70
  if (-not $removed) {
62
71
  Write-Host "Nothing named '$Name' is registered on this machine."
63
72
  }