ever-works-node 0.0.0-stage → 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.
@@ -0,0 +1,984 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Register the Ever Works node to run unattended on Windows.
4
+
5
+ .DESCRIPTION
6
+ Windows has two honest ways to run a Node.js program unattended, and
7
+ this script picks whichever the machine actually supports:
8
+
9
+ 1. A real Windows SERVICE, when a service wrapper (NSSM) is
10
+ present. Node cannot answer Service Control Manager messages on
11
+ its own, so `sc.exe create` pointed straight at node.exe
12
+ produces a service that starts and is then reported as "did not
13
+ respond in a timely fashion" (error 1053). A wrapper is what
14
+ makes it a genuine service, including a graceful SIGTERM-style
15
+ stop, which is what lets the node DRAIN its in-flight jobs.
16
+
17
+ 2. A SCHEDULED TASK at boot, otherwise. It runs unattended with no
18
+ extra software, survives reboots, and restarts on failure. It
19
+ is not a service (it will not appear in services.msc) and the
20
+ script says so rather than pretending.
21
+
22
+ Either way the registered program is node.exe running the package's
23
+ cli.js DIRECTLY. npm's global install puts `ever-works-node.ps1` and
24
+ `ever-works-node.cmd` shims on PATH; a service manager cannot launch
25
+ the .ps1 at all, and the .cmd puts cmd.exe between the service and
26
+ the node, where the console stop signal used for draining gets
27
+ swallowed ("Terminate batch job (Y/N)?").
28
+
29
+ WHO IT RUNS AS is the load-bearing decision. A fleet node executes
30
+ the enrolling user's work with the enrolling user's credentials:
31
+
32
+ * its config and heartbeat credential live in that user's
33
+ %APPDATA%\ever-works-node (config-store.ts resolveConfigPath),
34
+ written with inheritance stripped and exactly one ACE for that
35
+ user (node-io.ts restrictFileToOwnerWindows);
36
+ * its git credentials live in that user's Windows Credential
37
+ Manager vault, which the credential helper reads per-user;
38
+ * the Claude Code and Codex logins live in that user's profile.
39
+
40
+ `nssm install` calls CreateService with a NULL lpServiceStartName,
41
+ which the Win32 API defines as LocalSystem, and LocalSystem shares
42
+ none of those. The node then reports "not enrolled" (exit 3) and NSSM
43
+ restarts it every 10 seconds forever. -ServiceAccount therefore
44
+ defaults to the account running this installer and is applied on every
45
+ run; the preflight refuses to register a node that could not work.
46
+
47
+ The identity is applied through ChangeServiceConfigW rather than
48
+ `nssm set ObjectName`, because NSSM takes the password as a
49
+ command-line argument and a child process's command line is written to
50
+ persistent, sometimes off-box, telemetry. See
51
+ Get-ServiceIdentityInteropSource in install-service.logic.ps1 for the
52
+ whole argument, including the half of NSSM's job (granting
53
+ SeServiceLogonRight) this script therefore has to do itself.
54
+
55
+ Re-running the script against an existing service or task re-applies
56
+ the CURRENT flags (pins, -Work, -WorkspaceRoot) AND the current
57
+ identity; it never keeps the old configuration silently.
58
+
59
+ Enrollment is deliberately NOT done here: it consumes a one-time
60
+ token and is an explicit, interactive act. Run
61
+ `ever-works-node enroll --api-url <url> --token <token>` first, AS
62
+ the account the node will run as.
63
+
64
+ This file is ASCII-only on purpose. Windows PowerShell 5.1 decodes a
65
+ BOM-less script as the system ANSI codepage, so a UTF-8 em dash
66
+ arrives as three CP1252 characters ending in a curly double quote,
67
+ which PowerShell accepts as a string terminator. One em dash inside a
68
+ double-quoted string is a parse error on the hosts this installer
69
+ targets; windows-service-contract.internal.spec.ts pins the rule.
70
+
71
+ .PARAMETER Name
72
+ Service / task name. Default: EverWorksNode
73
+
74
+ .PARAMETER Work
75
+ Lease and execute platform work (adds --work). Without it the node
76
+ reports liveness and capabilities only.
77
+
78
+ .PARAMETER ServiceAccount
79
+ The account the node runs as. Default: the account running this
80
+ installer. Accepts DOMAIN\User, MACHINE\User, .\User, a bare local
81
+ name, a UPN (user@domain.tld), a group Managed Service Account
82
+ (DOMAIN\name$, no password), and the built-in service accounts (which
83
+ the preflight refuses, because they cannot see the enrolling user's
84
+ credential).
85
+
86
+ .PARAMETER ServicePassword
87
+ The account's password, as a SecureString, so the plaintext never
88
+ reaches your shell history or powershell.exe's own command line:
89
+
90
+ -ServicePassword (Read-Host -AsSecureString 'Service password')
91
+
92
+ Required for any normal user account on the service path; a gMSA and
93
+ the built-ins take none. On the scheduled-task path it is what turns
94
+ a password-less S4U logon into a real one (see -UseScheduledTask).
95
+ It is never echoed, never written to a log file, and never rendered
96
+ into a progress message or an exception. On the SERVICE path it never
97
+ reaches a command line either; on the scheduled-task path
98
+ Register-ScheduledTask takes it as an ordinary cmdlet parameter, which
99
+ PowerShell module logging would record where that is enabled.
100
+
101
+ .PARAMETER UseScheduledTask
102
+ Skip the NSSM lookup and register a scheduled task.
103
+
104
+ .PARAMETER DryRun
105
+ Print every command that would run and change nothing. Identical to
106
+ the built-in -WhatIf, which is also accepted. The preflight still
107
+ runs, so this is how to check a machine (or five) before touching it.
108
+
109
+ .PARAMETER ClaudePath
110
+ Pin the Claude Code executable the node may run for model-cli agent
111
+ tasks (adds --claude-path). Use it when the service account's PATH
112
+ does not resolve `claude`, e.g.
113
+ C:\Users\<user>\AppData\Roaming\npm\claude.cmd. A pin that does not
114
+ resolve DISABLES that CLI rather than falling back to PATH.
115
+
116
+ .PARAMETER CodexPath
117
+ Pin the Codex executable (adds --codex-path). Same rules as -ClaudePath.
118
+
119
+ .PARAMETER WorkspaceRoot
120
+ Absolute, drive- or UNC-rooted directory the node keeps its per-Task
121
+ worktrees under (adds --workspace-root). Default:
122
+ EVER_WORKS_NODE_WORKSPACE_ROOT, then ~\.ever-works\fleet-workspaces of
123
+ the account the service runs as. A rooted-but-driveless path such as
124
+ \fleet is refused: the CLI would complete it with whatever drive the
125
+ service manager started the node on.
126
+
127
+ .PARAMETER CliPath
128
+ The node's cli.js to run. Default: the published package,
129
+ <npm root -g>\ever-works-node\cli.js. From a source checkout pass
130
+ apps\node\dist\cli.js.
131
+
132
+ .EXAMPLE
133
+ .\install-service.ps1 -Work -ServicePassword (Read-Host -AsSecureString)
134
+
135
+ .EXAMPLE
136
+ .\install-service.ps1 -Work -DryRun
137
+
138
+ .EXAMPLE
139
+ .\install-service.ps1 -Work -ServiceAccount CONTOSO\fleet-svc `
140
+ -ServicePassword (Read-Host -AsSecureString) `
141
+ -ClaudePath "$env:APPDATA\npm\claude.cmd" -WorkspaceRoot D:\fleet-workspaces
142
+
143
+ .EXAMPLE
144
+ .\install-service.ps1 -Work -CliPath C:\src\ever-works\apps\node\dist\cli.js
145
+ #>
146
+ [CmdletBinding(SupportsShouldProcess = $true)]
147
+ param(
148
+ [string] $Name = 'EverWorksNode',
149
+ [switch] $Work,
150
+ [string] $ServiceAccount,
151
+ [SecureString] $ServicePassword,
152
+ [switch] $UseScheduledTask,
153
+ [switch] $DryRun,
154
+ [string] $ClaudePath,
155
+ [string] $CodexPath,
156
+ [string] $WorkspaceRoot,
157
+ [string] $CliPath
158
+ )
159
+
160
+ $ErrorActionPreference = 'Stop'
161
+
162
+ # -DryRun is the name an operator reaches for; -WhatIf is the name
163
+ # PowerShell reaches for. Route both through the SAME ShouldProcess
164
+ # decision so there is exactly one code path that decides whether this
165
+ # machine gets touched, and no way for the two to disagree.
166
+ if ($DryRun) { $WhatIfPreference = $true }
167
+
168
+ . (Join-Path $PSScriptRoot 'install-service.logic.ps1')
169
+
170
+ function Test-Administrator {
171
+ $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
172
+ $principal = New-Object Security.Principal.WindowsPrincipal($identity)
173
+ return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
174
+ }
175
+
176
+ # Run a native tool with a command line built HERE and handed to
177
+ # CreateProcess verbatim. Calling `& nssm ...` would let Windows PowerShell
178
+ # 5.1 re-quote every element that contains a space WITHOUT escaping the
179
+ # quotes inside it, so a pinned path such as
180
+ # "C:\Program Files\nodejs\claude.cmd" arrives split into two arguments.
181
+ # Start-Process with a single -ArgumentList string passes it unmodified on
182
+ # every PowerShell version.
183
+ #
184
+ # DisplayCommandLine is mandatory and is always the rendering the plan
185
+ # produced: the failure message must describe the same call the dry run
186
+ # printed, and must never interpolate an argument vector of its own.
187
+ #
188
+ # Nothing routed through here ever carries a secret. The one call that
189
+ # needs the service password does not create a process at all
190
+ # (Set-ServiceLogonIdentity).
191
+ function Invoke-NativeStep {
192
+ param(
193
+ [Parameter(Mandatory = $true)][string] $FilePath,
194
+ [Parameter(Mandatory = $true)][string[]] $Arguments,
195
+ [Parameter(Mandatory = $true)][string] $DisplayCommandLine,
196
+ [Parameter(Mandatory = $true)][string] $What
197
+ )
198
+ $commandLine = ConvertTo-CommandLine $Arguments
199
+ $process = Start-Process -FilePath $FilePath -ArgumentList $commandLine -Wait -NoNewWindow -PassThru
200
+ if ($process.ExitCode -ne 0) {
201
+ throw "$What failed with exit code $($process.ExitCode) (command line: $DisplayCommandLine)."
202
+ }
203
+ }
204
+
205
+ # --- Service identity -----------------------------------------------------
206
+
207
+ function Install-ServiceIdentityInteropType {
208
+ if ('EverWorksServiceIdentity' -as [type]) { return }
209
+ try {
210
+ Add-Type -TypeDefinition (Get-ServiceIdentityInteropSource) -Language CSharp
211
+ }
212
+ catch {
213
+ throw "Could not compile the service-identity helper ($($_.Exception.Message)). This needs the .NET Framework C# compiler that ships with Windows PowerShell 5.1."
214
+ }
215
+ }
216
+
217
+ <#
218
+ .SYNOPSIS
219
+ Point an existing service at an account, without the password ever
220
+ reaching a command line.
221
+
222
+ .DESCRIPTION
223
+ The password is unwrapped straight from the SecureString into UNMANAGED
224
+ memory and passed to ChangeServiceConfigW as a pointer. No child
225
+ process is created, no cmdlet parameter is bound, and no managed String
226
+ is produced - a managed String is immutable and cannot be zeroed, so it
227
+ would survive on the GC heap, in a crash dump, and in the page file.
228
+ The buffer is zeroed and freed in the finally.
229
+
230
+ An EMPTY unmanaged string is passed for the accounts that take no
231
+ password (gMSA, virtual, built-in). A NULL pointer would mean "keep the
232
+ stored password", which is not the same thing.
233
+ #>
234
+ function Set-ServiceLogonIdentity {
235
+ param(
236
+ [Parameter(Mandatory = $true)][string] $Name,
237
+ [Parameter(Mandatory = $true)][string] $Account,
238
+ [Parameter(Mandatory = $true)][byte[]] $AccountSid,
239
+ [switch] $GrantLogonRight,
240
+ [AllowNull()][SecureString] $Password
241
+ )
242
+
243
+ Install-ServiceIdentityInteropType
244
+
245
+ # ChangeServiceConfig does NOT grant SeServiceLogonRight; without it the
246
+ # service configures cleanly and then fails to start with error 1069.
247
+ # The built-ins and virtual accounts already hold it implicitly.
248
+ if ($GrantLogonRight) {
249
+ [EverWorksServiceIdentity]::GrantServiceLogonRight($AccountSid)
250
+ }
251
+
252
+ $passwordPointer = [IntPtr]::Zero
253
+ try {
254
+ if ($null -ne $Password -and $Password.Length -gt 0) {
255
+ $passwordPointer = [Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUnicode($Password)
256
+ }
257
+ else {
258
+ $passwordPointer = [Runtime.InteropServices.Marshal]::StringToCoTaskMemUni('')
259
+ }
260
+ [EverWorksServiceIdentity]::SetServiceLogonAccount($Name, $Account, $passwordPointer)
261
+ }
262
+ finally {
263
+ if ($passwordPointer -ne [IntPtr]::Zero) {
264
+ [Runtime.InteropServices.Marshal]::ZeroFreeCoTaskMemUnicode($passwordPointer)
265
+ }
266
+ }
267
+ }
268
+
269
+ # --- Identity probes (read-only; the deciding is done in the .logic file) --
270
+
271
+ function Resolve-AccountSid {
272
+ param([Parameter(Mandatory = $true)][string] $Account)
273
+ try {
274
+ return (New-Object Security.Principal.NTAccount($Account)).Translate([Security.Principal.SecurityIdentifier]).Value
275
+ }
276
+ catch {
277
+ return $null
278
+ }
279
+ }
280
+
281
+ # LsaAddAccountRights takes a PSID, so the SID has to reach it as its binary
282
+ # form rather than as the S-1-5-21-... string.
283
+ function ConvertTo-SidBinaryForm {
284
+ param([Parameter(Mandatory = $true)][string] $Sid)
285
+ $identifier = New-Object Security.Principal.SecurityIdentifier($Sid)
286
+ $bytes = New-Object byte[] $identifier.BinaryLength
287
+ $identifier.GetBinaryForm($bytes, 0)
288
+ return , $bytes
289
+ }
290
+
291
+ # The profile root is where EVERY path the node cares about hangs off:
292
+ # %APPDATA% for the config, ~\.claude and ~\.codex for the CLI logins,
293
+ # ~\.ever-works for the default workspace root. HKLM ProfileList is the
294
+ # only way to learn another account's profile without logging on as it.
295
+ # ProfileImagePath is a REG_EXPAND_SZ, so expand it.
296
+ function Get-AccountProfilePath {
297
+ param([Parameter(Mandatory = $true)][string] $Sid)
298
+ $key = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$Sid"
299
+ if (-not (Test-Path -LiteralPath $key)) { return $null }
300
+ try {
301
+ $value = (Get-ItemProperty -LiteralPath $key -Name 'ProfileImagePath').ProfileImagePath
302
+ if ([string]::IsNullOrWhiteSpace($value)) { return $null }
303
+ return [Environment]::ExpandEnvironmentVariables($value)
304
+ }
305
+ catch {
306
+ return $null
307
+ }
308
+ }
309
+
310
+ # Read one DACL and hand the DECISION to the pure rule in the .logic file.
311
+ # Everything here is I/O: fetch the ACL, reduce each ACE to the three facts
312
+ # the rule needs, and translate the identity reference to a SID (an ACE for
313
+ # a deleted account cannot be translated and is skipped).
314
+ function Get-PathAccessForSid {
315
+ param(
316
+ [Parameter(Mandatory = $true)][string] $Path,
317
+ [Parameter(Mandatory = $true)][string] $Sid,
318
+ [Parameter(Mandatory = $true)][string] $IdentityKind,
319
+ [Parameter(Mandatory = $true)][Security.AccessControl.FileSystemRights] $Rights
320
+ )
321
+ try {
322
+ $acl = Get-Acl -LiteralPath $Path
323
+ }
324
+ catch {
325
+ return 'unknown'
326
+ }
327
+
328
+ $rules = [Collections.Generic.List[object]]::new()
329
+ foreach ($rule in $acl.Access) {
330
+ $ruleSid = $null
331
+ if ($rule.IdentityReference -is [Security.Principal.SecurityIdentifier]) {
332
+ $ruleSid = $rule.IdentityReference.Value
333
+ }
334
+ else {
335
+ try { $ruleSid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value }
336
+ catch { continue }
337
+ }
338
+ $rules.Add([pscustomobject]@{
339
+ SidValue = $ruleSid
340
+ Rights = [int] $rule.FileSystemRights
341
+ IsDeny = ($rule.AccessControlType -eq [Security.AccessControl.AccessControlType]::Deny)
342
+ })
343
+ }
344
+
345
+ return (Get-AccessVerdictFromAcl `
346
+ -Rules $rules.ToArray() `
347
+ -IsProtected ([bool] $acl.AreAccessRulesProtected) `
348
+ -ConsideredSids (Get-AccessProbeSidList -Sid $Sid -IdentityKind $IdentityKind) `
349
+ -WantedRights ([int] $Rights))
350
+ }
351
+
352
+ function Get-NearestExistingAncestor {
353
+ param([Parameter(Mandatory = $true)][string] $Path)
354
+ $candidate = $Path
355
+ while (-not [string]::IsNullOrWhiteSpace($candidate) -and -not (Test-Path -LiteralPath $candidate)) {
356
+ $parent = Split-Path -Parent $candidate
357
+ if ($parent -eq $candidate) { return $null }
358
+ $candidate = $parent
359
+ }
360
+ if ([string]::IsNullOrWhiteSpace($candidate)) { return $null }
361
+ return $candidate
362
+ }
363
+
364
+ # The node reads the MACHINE environment block, not this shell's, so read
365
+ # the machine scope explicitly rather than trusting $env: to agree.
366
+ function Get-MachineEnvironmentVariable {
367
+ param([Parameter(Mandatory = $true)][string] $VariableName)
368
+ $value = [Environment]::GetEnvironmentVariable($VariableName, 'Machine')
369
+ if ([string]::IsNullOrWhiteSpace($value)) { return '' }
370
+ return $value.Trim()
371
+ }
372
+
373
+ function Get-ModelCliPinState {
374
+ param(
375
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $PinnedPath,
376
+ [Parameter(Mandatory = $true)][string] $Sid,
377
+ [Parameter(Mandatory = $true)][string] $IdentityKind
378
+ )
379
+ if ([string]::IsNullOrWhiteSpace($PinnedPath)) {
380
+ return (Get-ModelCliPinStateFromFacts -PinnedPath $PinnedPath -Exists $false -Extension '' -AccessVerdict 'unknown')
381
+ }
382
+ $exists = Test-Path -LiteralPath $PinnedPath -PathType Leaf
383
+ $extension = ''
384
+ $verdict = 'unknown'
385
+ if ($exists) {
386
+ $extension = [IO.Path]::GetExtension($PinnedPath)
387
+ $verdict = Get-PathAccessForSid `
388
+ -Path $PinnedPath `
389
+ -Sid $Sid `
390
+ -IdentityKind $IdentityKind `
391
+ -Rights ([Security.AccessControl.FileSystemRights]::ReadAndExecute)
392
+ }
393
+ return (Get-ModelCliPinStateFromFacts -PinnedPath $PinnedPath -Exists $exists -Extension $extension -AccessVerdict $verdict)
394
+ }
395
+
396
+ <#
397
+ .SYNOPSIS
398
+ Measure everything Get-ServiceInstallPreflightFinding needs to decide.
399
+
400
+ .DESCRIPTION
401
+ Every path is resolved FOR THE CHOSEN ACCOUNT rather than for the
402
+ elevated installer, which is the whole point: an admin running this
403
+ can read its own config and its own CLI logins whatever identity the
404
+ service ends up with.
405
+ #>
406
+ function Get-ServiceInstallPreflightProbe {
407
+ param(
408
+ [Parameter(Mandatory = $true)] $Identity,
409
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $ClaudePin,
410
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $CodexPin,
411
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $WorkspaceRootFlag,
412
+ [Parameter(Mandatory = $true)][string] $StateDir,
413
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]] $LogPaths
414
+ )
415
+
416
+ $probe = @{
417
+ AccountResolved = $false
418
+ AccountSid = ''
419
+ ProfilePath = $null
420
+ ConfigPath = ''
421
+ ConfigPathSource = 'profile'
422
+ ConfigExists = $false
423
+ ConfigAccess = 'unknown'
424
+ ClaudePath = $ClaudePin
425
+ ClaudePathState = 'unpinned'
426
+ ClaudeLoginPath = ''
427
+ ClaudeLoginExists = $false
428
+ CodexPath = $CodexPin
429
+ CodexPathState = 'unpinned'
430
+ CodexLoginPath = ''
431
+ CodexLoginExists = $false
432
+ WorkspaceRoot = ''
433
+ WorkspaceRootSource = 'profile-default'
434
+ WorkspaceRootProbedAncestor = ''
435
+ WorkspaceRootAccess = 'unknown'
436
+ WorkspaceRootUnderSystemProfile = $false
437
+ StateDir = $StateDir
438
+ StateDirAccess = 'unknown'
439
+ UnwritableLogPaths = @()
440
+ }
441
+
442
+ $sid = Resolve-AccountSid -Account $Identity.SamAccountName
443
+ if ($null -eq $sid) { return $probe }
444
+ $probe.AccountResolved = $true
445
+ $probe.AccountSid = $sid
446
+
447
+ # The state directory and the two log files. Checked for EVERY account,
448
+ # before the profile lookup, because they are the one pair of paths that
449
+ # does not hang off the profile - and because a log file left behind by a
450
+ # previous identity is the single most likely reason a re-installed node
451
+ # dies with nothing in either log.
452
+ if (Test-Path -LiteralPath $StateDir) {
453
+ $probe.StateDirAccess = Get-PathAccessForSid `
454
+ -Path $StateDir `
455
+ -Sid $sid `
456
+ -IdentityKind $Identity.Kind `
457
+ -Rights ([Security.AccessControl.FileSystemRights]::Modify)
458
+ }
459
+ else {
460
+ # Created and granted below in the same run.
461
+ $probe.StateDirAccess = 'granted'
462
+ }
463
+ $unwritableLogs = [Collections.Generic.List[string]]::new()
464
+ foreach ($log in $LogPaths) {
465
+ if (-not (Test-Path -LiteralPath $log -PathType Leaf)) { continue }
466
+ $logAccess = Get-PathAccessForSid `
467
+ -Path $log `
468
+ -Sid $sid `
469
+ -IdentityKind $Identity.Kind `
470
+ -Rights ([Security.AccessControl.FileSystemRights]::Modify)
471
+ if ($logAccess -eq 'denied') { $unwritableLogs.Add($log) }
472
+ }
473
+ $probe.UnwritableLogPaths = $unwritableLogs.ToArray()
474
+
475
+ $profilePath = Get-AccountProfilePath -Sid $sid
476
+ $probe.ProfilePath = $profilePath
477
+ if ([string]::IsNullOrWhiteSpace($profilePath)) { return $probe }
478
+
479
+ # Config: EVER_WORKS_NODE_CONFIG wins, else %APPDATA% of THAT account.
480
+ $configOverride = Get-MachineEnvironmentVariable -VariableName 'EVER_WORKS_NODE_CONFIG'
481
+ if ($configOverride) {
482
+ $probe.ConfigPath = $configOverride
483
+ $probe.ConfigPathSource = 'environment-override'
484
+ }
485
+ else {
486
+ $probe.ConfigPath = Join-Path $profilePath 'AppData\Roaming\ever-works-node\node-config.json'
487
+ }
488
+ $probe.ConfigExists = Test-Path -LiteralPath $probe.ConfigPath -PathType Leaf
489
+ if ($probe.ConfigExists) {
490
+ $probe.ConfigAccess = Get-PathAccessForSid `
491
+ -Path $probe.ConfigPath `
492
+ -Sid $sid `
493
+ -IdentityKind $Identity.Kind `
494
+ -Rights ([Security.AccessControl.FileSystemRights]::Read)
495
+ }
496
+
497
+ # Model CLIs: the executable AND the login, because a pin that resolves
498
+ # without a login makes the node advertise the capability and then fail
499
+ # every job it is sent.
500
+ $probe.ClaudePathState = Get-ModelCliPinState -PinnedPath $ClaudePin -Sid $sid -IdentityKind $Identity.Kind
501
+ $probe.ClaudeLoginPath = Join-Path $profilePath '.claude\.credentials.json'
502
+ $probe.ClaudeLoginExists = Test-Path -LiteralPath $probe.ClaudeLoginPath
503
+ $probe.CodexPathState = Get-ModelCliPinState -PinnedPath $CodexPin -Sid $sid -IdentityKind $Identity.Kind
504
+ $probe.CodexLoginPath = Join-Path $profilePath '.codex'
505
+ $probe.CodexLoginExists = Test-Path -LiteralPath $probe.CodexLoginPath
506
+
507
+ # Workspace root: resolve the same precedence the node will
508
+ # (defaultFleetTaskWorkspaceRoot in workspaces/fleet-task-workspace.ts).
509
+ if ($WorkspaceRootFlag) {
510
+ $probe.WorkspaceRoot = $WorkspaceRootFlag
511
+ $probe.WorkspaceRootSource = 'flag'
512
+ }
513
+ else {
514
+ $configured = Get-MachineEnvironmentVariable -VariableName 'EVER_WORKS_NODE_WORKSPACE_ROOT'
515
+ if (-not $configured) { $configured = Get-MachineEnvironmentVariable -VariableName 'EW_WORKSPACES_DIR' }
516
+ if ($configured) {
517
+ $probe.WorkspaceRoot = $configured
518
+ $probe.WorkspaceRootSource = 'environment'
519
+ }
520
+ else {
521
+ $probe.WorkspaceRoot = Join-Path $profilePath '.ever-works\fleet-workspaces'
522
+ }
523
+ }
524
+
525
+ $systemProfileRoots = @(
526
+ (Join-Path $env:SystemRoot 'System32\config\systemprofile'),
527
+ (Join-Path $env:SystemRoot 'SysWOW64\config\systemprofile'),
528
+ (Join-Path $env:SystemRoot 'ServiceProfiles')
529
+ )
530
+ foreach ($root in $systemProfileRoots) {
531
+ if (Test-PathUnder -Path $probe.WorkspaceRoot -Ancestor $root) {
532
+ $probe.WorkspaceRootUnderSystemProfile = $true
533
+ }
534
+ }
535
+
536
+ $ancestor = Get-NearestExistingAncestor -Path $probe.WorkspaceRoot
537
+ if ($null -ne $ancestor) {
538
+ $probe.WorkspaceRootProbedAncestor = $ancestor
539
+ if (Test-PathUnder -Path $ancestor -Ancestor $profilePath) {
540
+ # The account owns its own profile tree; no ACL walk needed.
541
+ $probe.WorkspaceRootAccess = 'granted'
542
+ }
543
+ else {
544
+ $probe.WorkspaceRootAccess = Get-PathAccessForSid `
545
+ -Path $ancestor `
546
+ -Sid $sid `
547
+ -IdentityKind $Identity.Kind `
548
+ -Rights ([Security.AccessControl.FileSystemRights]::Write)
549
+ }
550
+ }
551
+
552
+ return $probe
553
+ }
554
+
555
+ # --- Preconditions --------------------------------------------------------
556
+
557
+ # One ShouldProcess call decides for the whole run, so -WhatIf / -DryRun
558
+ # produce ONE "what if" line followed by the full rendered plan rather than
559
+ # a per-cmdlet dribble. Everything that would change this machine is behind
560
+ # $commitChanges.
561
+ $commitChanges = $PSCmdlet.ShouldProcess(
562
+ "$Name on $env:COMPUTERNAME",
563
+ 'register and start the Ever Works node')
564
+
565
+ if (-not (Test-Administrator)) {
566
+ if ($commitChanges) {
567
+ throw 'This script must run from an elevated PowerShell session.'
568
+ }
569
+ # A dry run is read-only, and rehearsing five remote machines should not
570
+ # need five elevated shells. Say what the reduced visibility costs.
571
+ Write-Warning 'Not elevated. This dry run still works, but some preflight probes (ACLs on another account''s files) may answer "unknown" where an elevated run would be conclusive.'
572
+ }
573
+
574
+ # --- What to run: node.exe + the package's cli.js (never npm's shims) -----
575
+
576
+ $node = Get-Command 'node.exe' -ErrorAction SilentlyContinue
577
+ if ($null -eq $node) {
578
+ throw 'node.exe is not on PATH. Install Node.js >= 22 first.'
579
+ }
580
+ $nodeExe = $node.Source
581
+
582
+ if ($CliPath) {
583
+ $cliJs = $CliPath
584
+ }
585
+ else {
586
+ $npm = Get-Command 'npm.cmd' -ErrorAction SilentlyContinue
587
+ if ($null -eq $npm) { $npm = Get-Command 'npm' -ErrorAction SilentlyContinue }
588
+ if ($null -eq $npm) {
589
+ throw 'npm is not on PATH; pass -CliPath <path to ever-works-node cli.js> instead.'
590
+ }
591
+ $globalRoot = (& $npm.Source root -g | Select-Object -Last 1).Trim()
592
+ $cliJs = Join-Path $globalRoot 'ever-works-node\cli.js'
593
+ }
594
+ if (-not (Test-Path -LiteralPath $cliJs -PathType Leaf)) {
595
+ # Refuse rather than register something that will fail on every start.
596
+ throw "ever-works-node's cli.js was not found at '$cliJs'. Install it first (npm install -g ever-works-node) or pass -CliPath <path to cli.js> (from a source checkout: apps\node\dist\cli.js)."
597
+ }
598
+ $cliJs = (Resolve-Path -LiteralPath $cliJs).Path
599
+
600
+ # --- The argument line, shared by both mechanisms -------------------------
601
+
602
+ $arguments = @($cliJs, 'start')
603
+ if ($Work) { $arguments += '--work' }
604
+ if ($ClaudePath) { $arguments += @('--claude-path', $ClaudePath) }
605
+ if ($CodexPath) { $arguments += @('--codex-path', $CodexPath) }
606
+ if ($WorkspaceRoot) {
607
+ # Same rule as the CLI's parseWorkspaceRoot: a drive root (D:\...) or a
608
+ # UNC path naming a server and a share. [IO.Path]::IsPathRooted would
609
+ # also accept \fleet and the drive-relative C:fleet, both of which the
610
+ # CLI rejects - the service would then crash-loop on a usage error.
611
+ if ($WorkspaceRoot -notmatch '^[A-Za-z]:[\\/]|^[\\/]{2}[^\\/]+[\\/]+[^\\/]+') {
612
+ throw "-WorkspaceRoot must be an absolute, drive- or UNC-rooted directory such as D:\fleet-workspaces (got '$WorkspaceRoot')."
613
+ }
614
+ $arguments += @('--workspace-root', $WorkspaceRoot)
615
+ }
616
+ # Quoted once, here. NSSM receives this whole line as ONE argument (see
617
+ # Invoke-NativeStep); New-ScheduledTaskAction is a cmdlet and takes it verbatim.
618
+ $argumentLine = ConvertTo-CommandLine $arguments
619
+
620
+ $stateDir = Join-Path $env:ProgramData 'ever-works-node'
621
+ $stdoutLog = Join-Path $stateDir 'node.log'
622
+ $stderrLog = Join-Path $stateDir 'node.err.log'
623
+ $icaclsExe = Join-Path $env:SystemRoot 'System32\icacls.exe'
624
+
625
+ $nssm = $null
626
+ if (-not $UseScheduledTask) {
627
+ $nssm = Get-Command 'nssm' -ErrorAction SilentlyContinue
628
+ }
629
+ $mechanism = 'task'
630
+ if ($null -ne $nssm) { $mechanism = 'service' }
631
+
632
+ # Said HERE, before the summary claims a mechanism, rather than in the
633
+ # middle of the registration output.
634
+ if ($mechanism -eq 'task') {
635
+ Write-Warning 'NSSM was not found - registering a Scheduled Task instead of a Windows service.'
636
+ Write-Warning 'The node will run unattended at boot, but will NOT appear in services.msc.'
637
+ Write-Warning 'Install NSSM (https://nssm.cc) and re-run this script for a real service.'
638
+ }
639
+
640
+ # --- Who it runs as, and whether that can possibly work -------------------
641
+
642
+ $currentIdentityName = [Security.Principal.WindowsIdentity]::GetCurrent().Name
643
+ $defaultDomain = $env:USERDOMAIN
644
+ $defaultUser = $env:USERNAME
645
+ if ($currentIdentityName -and $currentIdentityName.Contains('\')) {
646
+ $split = $currentIdentityName.IndexOf('\')
647
+ $defaultDomain = $currentIdentityName.Substring(0, $split)
648
+ $defaultUser = $currentIdentityName.Substring($split + 1)
649
+ }
650
+
651
+ $identity = Resolve-ServiceAccountIdentity `
652
+ -Account $ServiceAccount `
653
+ -MachineName $env:COMPUTERNAME `
654
+ -DefaultDomain $defaultDomain `
655
+ -DefaultUser $defaultUser
656
+
657
+ # Presence is not enough. `Read-Host -AsSecureString` answered with an
658
+ # immediate Enter yields a NON-NULL, zero-length SecureString - the single
659
+ # most likely slip on this parameter. Treating that as "a password was
660
+ # supplied" would sail past the password-required check, set an empty
661
+ # password, and fail at first start with SCM error 1069, AFTER the machine
662
+ # had been changed. SecureString.Length is a plaintext-free property.
663
+ $hasPassword = ($null -ne $ServicePassword -and $ServicePassword.Length -gt 0)
664
+
665
+ $probe = Get-ServiceInstallPreflightProbe `
666
+ -Identity $identity `
667
+ -ClaudePin $ClaudePath `
668
+ -CodexPin $CodexPath `
669
+ -WorkspaceRootFlag $WorkspaceRoot `
670
+ -StateDir $stateDir `
671
+ -LogPaths @($stdoutLog, $stderrLog)
672
+
673
+ $findings = Get-ServiceInstallPreflightFinding `
674
+ -Identity $identity `
675
+ -HasPassword $hasPassword `
676
+ -Mechanism $mechanism `
677
+ -Probe $probe
678
+
679
+ Write-Host ''
680
+ Write-Host "Identity: $($identity.SamAccountName) ($($identity.Description))"
681
+ Write-Host "Mechanism: $(if ($mechanism -eq 'service') { "Windows service via NSSM ($($nssm.Source))" } else { 'Scheduled Task at boot' })"
682
+ Write-Host "Config: $($probe.ConfigPath)"
683
+ Write-Host "Worktrees: $($probe.WorkspaceRoot)"
684
+ Write-Host ''
685
+
686
+ $preflightLines = Format-PreflightFindingReport -Findings $findings
687
+ if ($preflightLines.Count -eq 0) {
688
+ Write-Host 'Preflight: clean.'
689
+ }
690
+ else {
691
+ Write-Host 'Preflight:'
692
+ foreach ($line in $preflightLines) { Write-Host $line }
693
+ }
694
+ Write-Host ''
695
+
696
+ $preflightBlocked = Test-PreflightBlocked -Findings $findings
697
+ if ($preflightBlocked -and $commitChanges) {
698
+ # Refuse BEFORE anything is registered. A node installed under an
699
+ # identity that cannot read its own credential starts, reports "not
700
+ # enrolled", and is restarted every 10 seconds forever - a failure that
701
+ # looks like a platform problem and is not.
702
+ throw "Preflight failed for $($identity.SamAccountName); nothing was changed. Fix the errors above, or pass a different -ServiceAccount."
703
+ }
704
+
705
+ # --- Register -------------------------------------------------------------
706
+
707
+ if (-not $commitChanges) {
708
+ Write-Host 'DRY RUN - the commands below would run; nothing on this machine is changed.'
709
+ Write-Host ''
710
+ }
711
+
712
+ if ($commitChanges -and -not (Test-Path $stateDir)) {
713
+ New-Item -ItemType Directory -Path $stateDir | Out-Null
714
+ }
715
+ elseif (-not $commitChanges -and -not (Test-Path $stateDir)) {
716
+ Write-Host " New-Item -ItemType Directory -Path $stateDir"
717
+ }
718
+
719
+ # The account has to be able to write its own logs. Files created inside a
720
+ # %ProgramData% subfolder by a privileged identity inherit only
721
+ # BUILTIN\Users: ReadAndExecute, and a log left behind by a LocalSystem-era
722
+ # install grants the new account nothing - which the task branch would then
723
+ # fail on inside cmd.exe, before the node runs, with nothing in either log.
724
+ $existingLogPaths = @(@($stdoutLog, $stderrLog) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf })
725
+ $aclPlan = New-StateDirectoryAclPlan `
726
+ -StateDir $stateDir `
727
+ -ExistingLogPaths $existingLogPaths `
728
+ -Account $identity.SamAccountName
729
+ foreach ($step in $aclPlan) {
730
+ $rendered = Format-NativeCommandLine -FilePath $icaclsExe -Arguments $step.Arguments
731
+ if (-not $commitChanges) {
732
+ Write-Host " $rendered"
733
+ continue
734
+ }
735
+ Invoke-NativeStep -FilePath $icaclsExe -Arguments $step.Arguments -DisplayCommandLine $rendered -What 'icacls /grant'
736
+ }
737
+
738
+ if ($mechanism -eq 'service') {
739
+ $serviceExists = $null -ne (Get-Service -Name $Name -ErrorAction SilentlyContinue)
740
+ if ($serviceExists) {
741
+ if ($commitChanges) {
742
+ Write-Host "Service '$Name' already exists; stopping it and re-applying the configuration."
743
+ Stop-Service -Name $Name -Force -ErrorAction SilentlyContinue
744
+ }
745
+ else {
746
+ Write-Host " Stop-Service -Name $Name -Force"
747
+ }
748
+ }
749
+
750
+ # Both branches converge on ONE plan, so a re-run applies the CURRENT
751
+ # flags AND the current identity instead of leaving the first install's
752
+ # configuration in place.
753
+ $plan = New-ServiceConfigurationPlan `
754
+ -Name $Name `
755
+ -NodeExe $nodeExe `
756
+ -ArgumentLine $argumentLine `
757
+ -StateDir $stateDir `
758
+ -ServiceLogonName $identity.ServiceLogonName `
759
+ -ServiceExists:$serviceExists `
760
+ -WithPassword:$hasPassword
761
+
762
+ # Anything after `nssm install` can fail - most likely the identity step,
763
+ # which is the one that validates the account and the password. Until it
764
+ # succeeds the service is still LocalSystem, which is precisely the state
765
+ # this whole change exists to remove, so a fresh install that fails
766
+ # part-way is UNDONE rather than left behind for someone to notice.
767
+ $installedHere = $false
768
+ try {
769
+ foreach ($step in $plan) {
770
+ $rendered = Format-ServicePlanStep -NssmPath $nssm.Source -Step $step
771
+ if (-not $commitChanges) {
772
+ Write-Host " $rendered"
773
+ continue
774
+ }
775
+ if ($step.Kind -eq 'identity') {
776
+ $passwordForStep = $null
777
+ if ($step.RequiresPassword) { $passwordForStep = $ServicePassword }
778
+ Set-ServiceLogonIdentity `
779
+ -Name $Name `
780
+ -Account $step.Account `
781
+ -AccountSid (ConvertTo-SidBinaryForm -Sid $probe.AccountSid) `
782
+ -GrantLogonRight:$identity.NeedsLogonRight `
783
+ -Password $passwordForStep
784
+ continue
785
+ }
786
+ Invoke-NativeStep `
787
+ -FilePath $nssm.Source `
788
+ -Arguments $step.Arguments `
789
+ -DisplayCommandLine $rendered `
790
+ -What "nssm $($step.Arguments[0])"
791
+ if ($step.Arguments[0] -eq 'install') { $installedHere = $true }
792
+ }
793
+
794
+ if ($commitChanges) {
795
+ # Inside the same try: a wrong password passes ChangeServiceConfig
796
+ # and fails HERE, with SCM error 1069.
797
+ Start-Service -Name $Name
798
+ Write-Host "Registered Windows service '$Name' (via NSSM) and started it."
799
+ }
800
+ else {
801
+ Write-Host " Start-Service -Name $Name"
802
+ }
803
+ }
804
+ catch {
805
+ $reason = $_.Exception.Message
806
+ if (-not $installedHere) {
807
+ throw "$reason The service '$Name' already existed and has NOT been removed, so it may now be half-configured and still running as its previous identity. Fix the cause and re-run this installer, or remove it with uninstall-service.ps1."
808
+ }
809
+ Stop-Service -Name $Name -Force -ErrorAction SilentlyContinue
810
+ $removeArguments = @('remove', $Name, 'confirm')
811
+ $removalError = ''
812
+ try {
813
+ Invoke-NativeStep `
814
+ -FilePath $nssm.Source `
815
+ -Arguments $removeArguments `
816
+ -DisplayCommandLine (Format-NativeCommandLine -FilePath $nssm.Source -Arguments $removeArguments) `
817
+ -What 'nssm remove'
818
+ }
819
+ catch {
820
+ $removalError = $_.Exception.Message
821
+ }
822
+ if ($removalError) {
823
+ throw "$reason The service '$Name' was created by this run and could NOT be removed again ($removalError). It is registered but not configured; remove it with uninstall-service.ps1 before retrying."
824
+ }
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
+ }
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
+
857
+ Write-Host " Application: $nodeExe"
858
+ Write-Host " AppParameters: $argumentLine"
859
+ Write-Host " RunAs: $($identity.ServiceLogonName)"
860
+ Write-Host " inspect: nssm get $Name AppParameters"
861
+ Write-Host " stop/start: Stop-Service $Name / Start-Service $Name"
862
+ }
863
+ else {
864
+ $taskPlan = New-ScheduledTaskRegistrationPlan -Identity $identity -WithPassword:$hasPassword
865
+
866
+ if (Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue) {
867
+ if ($commitChanges) {
868
+ Unregister-ScheduledTask -TaskName $Name -Confirm:$false
869
+ }
870
+ else {
871
+ Write-Host " Unregister-ScheduledTask -TaskName $Name -Confirm:`$false"
872
+ }
873
+ }
874
+
875
+ # Same two log files the service branch writes, via cmd.exe because
876
+ # New-ScheduledTaskAction has no redirection of its own. See
877
+ # New-ScheduledTaskActionSpec for why cmd.exe is acceptable HERE and
878
+ # refused for the service.
879
+ $actionSpec = New-ScheduledTaskActionSpec `
880
+ -ComSpec $env:ComSpec `
881
+ -NodeExe $nodeExe `
882
+ -ArgumentLine $argumentLine `
883
+ -StdoutPath $stdoutLog `
884
+ -StderrPath $stderrLog
885
+
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)
898
+ $trigger = New-ScheduledTaskTrigger -AtStartup
899
+ $settings = New-ScheduledTaskSettingsSet `
900
+ -AllowStartIfOnBatteries `
901
+ -DontStopIfGoingOnBatteries `
902
+ -StartWhenAvailable `
903
+ -RestartCount 5 `
904
+ -RestartInterval (New-TimeSpan -Minutes 1) `
905
+ -ExecutionTimeLimit (New-TimeSpan -Seconds 0)
906
+
907
+ $register = @{
908
+ TaskName = $Name
909
+ Action = $action
910
+ Trigger = $trigger
911
+ Settings = $settings
912
+ }
913
+ if ($taskPlan.ParameterSet -eq 'Principal') {
914
+ $principalArguments = @{}
915
+ foreach ($key in $taskPlan.PrincipalParameters.Keys) { $principalArguments[$key] = $taskPlan.PrincipalParameters[$key] }
916
+ $register['Principal'] = New-ScheduledTaskPrincipal @principalArguments
917
+ }
918
+ else {
919
+ foreach ($key in $taskPlan.RegisterParameters.Keys) { $register[$key] = $taskPlan.RegisterParameters[$key] }
920
+ }
921
+
922
+ if ($commitChanges) {
923
+ if ($taskPlan.ParameterSet -eq 'User') {
924
+ # Register-ScheduledTask takes the password as a PLAINTEXT cmdlet
925
+ # parameter and has no SecureString overload, so it is unwrapped at
926
+ # the last possible moment and dropped immediately afterwards.
927
+ #
928
+ # Stated rather than implied: PtrToStringBSTR produces a managed
929
+ # String, which is immutable and CANNOT be zeroed. ZeroFreeBSTR
930
+ # clears the BSTR only; the managed copies (this one, plus
931
+ # whatever the parameter binder makes) live until the GC collects
932
+ # them and are visible to a crash dump. PowerShell module logging
933
+ # (event 4103) would also record the bound value. The Task
934
+ # Scheduler's own API takes a BSTR password, so there is no
935
+ # secret-free route here the way there is for the service - which
936
+ # is one more reason the service is the recommended mechanism.
937
+ $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($ServicePassword)
938
+ try {
939
+ $register['Password'] = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
940
+ Register-ScheduledTask @register | Out-Null
941
+ }
942
+ finally {
943
+ [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
944
+ $register.Remove('Password')
945
+ }
946
+ }
947
+ else {
948
+ Register-ScheduledTask @register | Out-Null
949
+ }
950
+ Start-ScheduledTask -TaskName $Name
951
+ Write-Host "Registered scheduled task '$Name' and started it."
952
+ }
953
+ else {
954
+ # Render the parameter set that would ACTUALLY be used. The two are
955
+ # disjoint, so printing -User/-Password for a principal registration
956
+ # would describe a call this script never makes.
957
+ if ($taskPlan.ParameterSet -eq 'User') {
958
+ Write-Host " Register-ScheduledTask -TaskName $Name -Action <action> -Trigger <atStartup> -Settings <settings> -User $($identity.TaskUserId) -Password <redacted -ServicePassword> -RunLevel Highest"
959
+ }
960
+ else {
961
+ Write-Host " `$principal = New-ScheduledTaskPrincipal -UserId $($identity.TaskUserId) -LogonType $($taskPlan.LogonType) -RunLevel Highest"
962
+ Write-Host " Register-ScheduledTask -TaskName $Name -Action <action> -Trigger <atStartup> -Settings <settings> -Principal `$principal"
963
+ }
964
+ Write-Host " Start-ScheduledTask -TaskName $Name"
965
+ }
966
+
967
+ Write-Host " Rotate: $($rotationSpec.Execute) -EncodedCommand <logs past $($rotationPolicy.Bytes) bytes, keep $($rotationPolicy.Keep)>"
968
+ Write-Host " Execute: $($actionSpec.Execute)"
969
+ Write-Host " Argument: $($actionSpec.Argument)"
970
+ Write-Host " RunAs: $($identity.TaskUserId) (LogonType $($taskPlan.LogonType))"
971
+ Write-Host " stop/start: Stop-ScheduledTask -TaskName $Name / Start-ScheduledTask -TaskName $Name"
972
+ }
973
+
974
+ Write-Host ''
975
+ Write-Host "Logs: $stdoutLog and $stderrLog (rotated past $((Get-NodeLogRotationPolicy).Bytes) bytes)"
976
+ Write-Host 'Operating:'
977
+ Write-Host ' drain (finish in-flight work, take no more): ever-works-node pause'
978
+ Write-Host ' take work again: ever-works-node resume'
979
+ Write-Host ' retire this machine: ever-works-node unenroll'
980
+
981
+ if ($preflightBlocked -and -not $commitChanges) {
982
+ Write-Host ''
983
+ throw "Preflight failed for $($identity.SamAccountName). A real run would refuse to register anything; fix the errors above first."
984
+ }