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.
- package/LICENSE +661 -0
- package/README.md +385 -2
- package/cli.js +23585 -0
- package/package.json +47 -4
- package/packaging/README.md +295 -0
- package/packaging/systemd/ever-works-node@.service +64 -0
- package/packaging/systemd/install.sh +68 -0
- package/packaging/windows/install-service.logic.ps1 +1285 -0
- package/packaging/windows/install-service.ps1 +984 -0
- package/packaging/windows/test-install-service-logic.ps1 +835 -0
- package/packaging/windows/uninstall-service.ps1 +76 -0
|
@@ -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
|
+
}
|