centralu 0.1.0-beta.4 → 0.1.0-beta.41
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/bin/centralu.mjs +466 -117
- package/bin/node-floor.mjs +42 -0
- package/bin/platform.mjs +380 -0
- package/bin/semver.mjs +37 -4
- package/bin/serve-args.mjs +116 -0
- package/bin/serve-autostart.mjs +489 -0
- package/bin/serve-detach.mjs +289 -0
- package/bin/serve-files.mjs +299 -0
- package/bin/serve-handover.mjs +152 -0
- package/bin/serve-host.mjs +395 -0
- package/bin/serve-install.mjs +204 -0
- package/bin/serve-port.mjs +79 -0
- package/bin/serve-run.mjs +197 -0
- package/bin/serve-util.mjs +127 -0
- package/bin/serve-window.mjs +112 -0
- package/bin/serve-windows.mjs +171 -0
- package/bin/serve.mjs +37 -0
- package/bin/swap.mjs +48 -0
- package/package.json +10 -6
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `centralu serve --autostart on|off|status`: a `systemd --user` unit on Linux, a scheduled task at
|
|
3
|
+
* sign-in on Windows and for WSL, and the mark in the data folder that `--connection` reports.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
7
|
+
import { userInfo } from 'node:os'
|
|
8
|
+
import { join, posix } from 'node:path'
|
|
9
|
+
import { answerLine, replaceFile, runCaptured } from './serve-util.mjs'
|
|
10
|
+
import { serveDataDir } from './serve-files.mjs'
|
|
11
|
+
import { askPowershell, detachHow, encodedCommand, wslInterop, PS_MODULE_PATH, windowsArg, windowsPowershell, psText, wslDistro, wslUser } from './serve-windows.mjs'
|
|
12
|
+
import { keepLauncher, remoteLayout } from './serve-install.mjs'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What `--autostart on` records in the data folder: the entry it wrote, so `--connection` can say
|
|
16
|
+
* whether this machine starts its host at boot without asking systemd or Task Scheduler at every
|
|
17
|
+
* link start. `--autostart status` asks them, and corrects this file when it disagrees.
|
|
18
|
+
*/
|
|
19
|
+
export const AUTOSTART_FILE = 'autostart.json'
|
|
20
|
+
|
|
21
|
+
/** The variables the entry carries to the serve it starts: the data folder, and a host entry (development) */
|
|
22
|
+
export const AUTOSTART_ENV = ['CC_DATA_DIR', 'CENTRALU_HOST_ENTRY']
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How this machine would start `serve` at boot (docs/plans/remote-hub.md §10.4, owner decision 4),
|
|
26
|
+
* or null where it offers none:
|
|
27
|
+
*
|
|
28
|
+
* systemd Linux: a `systemd --user` unit, enabled for `default.target`, plus lingering so the
|
|
29
|
+
* user's manager starts at boot rather than at the first login
|
|
30
|
+
* task Windows: a per-user scheduled task at sign-in (no administrator needed). Also inside
|
|
31
|
+
* WSL: nothing in a stopped distro runs, so the Windows side has to start it, through
|
|
32
|
+
* `wsl.exe`, which then holds the distro for as long as the host runs
|
|
33
|
+
*
|
|
34
|
+
* WSL with interop off has no way to the Windows side, and a Linux without systemd (or macOS) has
|
|
35
|
+
* no user unit; those say so instead.
|
|
36
|
+
*/
|
|
37
|
+
export function autostartHow(platform, env, { interop = wslInterop, systemd = () => existsSync('/run/systemd/system') } = {}) {
|
|
38
|
+
if (platform === 'win32') return 'task'
|
|
39
|
+
if (platform !== 'linux') return null
|
|
40
|
+
if (env.WSL_DISTRO_NAME) return interop() ? 'task' : null
|
|
41
|
+
return systemd() ? 'systemd' : null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Why `autostartHow` found nothing, in one sentence */
|
|
45
|
+
export function autostartUnsupported(platform, env) {
|
|
46
|
+
if (platform === 'linux' && env.WSL_DISTRO_NAME) {
|
|
47
|
+
return 'Starting Centralu with WSL needs a Windows scheduled task, and this distro cannot run Windows programs (WSL interop is off)'
|
|
48
|
+
}
|
|
49
|
+
if (platform === 'linux') return 'This Linux does not run systemd, so there is no user unit to start Centralu at boot; start `centralu serve` from your own init system'
|
|
50
|
+
return `Starting Centralu at boot is offered on Linux and Windows; on ${platform === 'darwin' ? 'macOS' : platform}, run \`centralu serve --detach\` from a login item or launchd agent of your own`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A folder path as a comparable key on `platform`: either separator as `/`, `.` and `..` folded, no
|
|
55
|
+
* trailing separator, and lower case on Windows, whose paths do not differ by case
|
|
56
|
+
*/
|
|
57
|
+
export function pathKey(p, platform) {
|
|
58
|
+
let s = posix.normalize(String(p).replaceAll('\\', '/'))
|
|
59
|
+
if (s.length > 1) s = s.replace(/\/+$/, '')
|
|
60
|
+
return platform === 'win32' ? s.toLowerCase() : s
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The entry's name. One per data folder: the default one gets the plain name, any other (a second
|
|
65
|
+
* install, a test's temporary folder) a suffix from its path, so they never replace each other. A
|
|
66
|
+
* Windows laptop linked twice, as Windows and as a WSL distro, gets two tasks.
|
|
67
|
+
*
|
|
68
|
+
* Whether `dataDir` is the default is read the same way whatever OS runs this (`pathKey`), not with
|
|
69
|
+
* this Node's `join`: the folder may be written with either separator (`CC_DATA_DIR=C:/x` on Windows),
|
|
70
|
+
* and Windows paths do not differ by case, so `C:\Users\Me\.centralu` is the default folder too.
|
|
71
|
+
*/
|
|
72
|
+
export function autostartName(how, { dataDir, home, env, platform = process.platform, user = null }) {
|
|
73
|
+
const key = pathKey(dataDir, platform)
|
|
74
|
+
const own = key === pathKey(`${home}/.centralu`, platform) ? '' : createHash('sha256').update(key).digest('hex').slice(0, 8)
|
|
75
|
+
if (how === 'systemd') return `centralu-serve${own ? `-${own}` : ''}.service`
|
|
76
|
+
const distro = env.WSL_DISTRO_NAME && wslDistro(env.WSL_DISTRO_NAME)
|
|
77
|
+
/*
|
|
78
|
+
* The user too (#1224 review): tasks in Task Scheduler's root folder are one list for every account, and an
|
|
79
|
+
* administrator's ssh logon sees the others'. Without it, a second account's `--detach` on a shared PC would find and
|
|
80
|
+
* start the first account's task. Characters a task name cannot hold become `-`
|
|
81
|
+
*/
|
|
82
|
+
const who = user ? ` (${String(user).replace(/[\\/:*?"<>|]/g, '-')})` : ''
|
|
83
|
+
return `Centralu host${who}${distro ? ` (WSL ${distro})` : ''}${own ? ` ${own}` : ''}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The task's name before the user was part of it: an earlier version's entry, removed when this user's (`taskScript`) */
|
|
87
|
+
export function legacyTaskName({ dataDir, home, env, platform = process.platform }) {
|
|
88
|
+
return autostartName('task', { dataDir, home, env, platform })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What the entry runs: the managed launcher when the app installed Centralu here over ssh (its text
|
|
93
|
+
* never changes and it reads `current`, so an update needs no new entry), else the launcher `serve`
|
|
94
|
+
* keeps for an npm install. The order is the hub's lookup (`connectionCommand` in links/tunnel.ts).
|
|
95
|
+
*/
|
|
96
|
+
export function autostartLauncher(dataDir, platform, exists = existsSync) {
|
|
97
|
+
const file = platform === 'win32' ? 'centralu.cmd' : 'centralu'
|
|
98
|
+
for (const p of [join(remoteLayout(dataDir).bin, file), join(dataDir, 'bin', file)]) if (exists(p)) return p
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One value for a unit file, double-quoted; `$` doubled where systemd would expand it (ExecStart) */
|
|
103
|
+
function systemdQuote(value, { dollars = false } = {}) {
|
|
104
|
+
const s = String(value)
|
|
105
|
+
if (/[\r\n\0]/.test(s)) throw new Error(`cannot put ${JSON.stringify(s)} in a systemd unit`)
|
|
106
|
+
let q = s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')
|
|
107
|
+
if (dollars) q = q.replaceAll('$', '$$$$')
|
|
108
|
+
return `"${q}"`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The `systemd --user` unit (docs/agent-host.md §4.7 has the hand-written one this follows).
|
|
113
|
+
*
|
|
114
|
+
* - `serve` in the foreground: systemd is what keeps it outside any session.
|
|
115
|
+
* - `KillMode=mixed`: a stop sends SIGTERM to the launcher only, which passes one to the host, which
|
|
116
|
+
* stops its agents in order; the rest of the group is killed only after `TimeoutStopSec`.
|
|
117
|
+
* - `Restart=on-failure`, but bounded: a host that fails at every start (a port another program
|
|
118
|
+
* holds, a store from a newer build) is left failed after five tries in five minutes rather than
|
|
119
|
+
* started forever. A clean stop (`serve --stop`, as an update does) exits 0 and stays stopped.
|
|
120
|
+
*/
|
|
121
|
+
export function systemdUnit({ launcher, env }) {
|
|
122
|
+
return [
|
|
123
|
+
'# Written by `centralu serve --autostart on`; `centralu serve --autostart off` removes it.',
|
|
124
|
+
'[Unit]',
|
|
125
|
+
'Description=Centralu host (centralu serve)',
|
|
126
|
+
'StartLimitIntervalSec=300',
|
|
127
|
+
'StartLimitBurst=5',
|
|
128
|
+
'',
|
|
129
|
+
'[Service]',
|
|
130
|
+
'Type=simple',
|
|
131
|
+
...AUTOSTART_ENV.filter((k) => env[k]).map((k) => `Environment=${systemdQuote(`${k}=${env[k]}`)}`),
|
|
132
|
+
`ExecStart=${systemdQuote(launcher, { dollars: true })} serve`,
|
|
133
|
+
'KillMode=mixed',
|
|
134
|
+
'TimeoutStopSec=30',
|
|
135
|
+
'Restart=on-failure',
|
|
136
|
+
'RestartSec=10',
|
|
137
|
+
'',
|
|
138
|
+
'[Install]',
|
|
139
|
+
'WantedBy=default.target',
|
|
140
|
+
'',
|
|
141
|
+
].join('\n')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The PowerShell the scheduled task runs at sign-in, in a hidden window: `serve` in the foreground,
|
|
146
|
+
* for as long as it runs. Not `--detach`: a task is not an ssh session, so nothing ends it with one,
|
|
147
|
+
* and it needs no WMI, which some machines block (decision 3).
|
|
148
|
+
*
|
|
149
|
+
* Windows the launcher itself
|
|
150
|
+
* WSL `wsl.exe -d <distro> -u <user> --exec <launcher> serve`: that `wsl.exe` is a client of
|
|
151
|
+
* the distro for as long as the host runs, so WSL does not stop it (§10.4)
|
|
152
|
+
*
|
|
153
|
+
* The variables of `AUTOSTART_ENV` are set in the task (Windows) or passed through `env` (WSL, where
|
|
154
|
+
* the Windows side's environment does not reach the distro).
|
|
155
|
+
*/
|
|
156
|
+
export function taskActionScript({ how, launcher, env, distro = null, user = null }) {
|
|
157
|
+
const carried = AUTOSTART_ENV.filter((k) => env[k])
|
|
158
|
+
if (how === 'wmi') return [...carried.map((k) => `$env:${k} = ${psText(env[k])}`), `& ${psText(launcher)} serve`].join('\n')
|
|
159
|
+
const args = ['-d', wslDistro(distro), '-u', wslUser(user), '--cd', '~', '--exec', ...(carried.length ? ['/usr/bin/env', ...carried.map((k) => `${k}=${env[k]}`)] : []), launcher, 'serve']
|
|
160
|
+
// PowerShell writes the command line itself; what windowsArg could not quote, it would not either
|
|
161
|
+
for (const a of args) windowsArg(a)
|
|
162
|
+
return [`$a = @(${args.map(psText).join(', ')})`, "& (Join-Path $env:SystemRoot 'System32\\wsl.exe') @a"].join('\n')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const TASK_WORD = 'CENTRALU-AUTOSTART'
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The PowerShell that registers, changes, removes or reads the scheduled task, printing one `CENTRALU-AUTOSTART` line.
|
|
169
|
+
* Registered for this user only, run only while they are signed in (`Interactive`, `Limited`): the kind of task a user
|
|
170
|
+
* may create without an administrator. On a laptop, it runs on battery too, and with no time limit (the default ends a
|
|
171
|
+
* task after 72 hours).
|
|
172
|
+
*
|
|
173
|
+
* on the task with an `AtLogOn` trigger: the host starts at sign-in
|
|
174
|
+
* off the same task without its trigger (#1224): kept for `serve --detach`, which starts the host in the
|
|
175
|
+
* person's sign-in through it; a task without a trigger reads as autostart off (`status` says `notrigger`)
|
|
176
|
+
* remove the task gone (uninstall)
|
|
177
|
+
* status `state none`, `state notrigger` or the task's state
|
|
178
|
+
* signin `serve --detach`'s start (#1224): when this user has a desktop, the task is checked and started. It is
|
|
179
|
+
* registered (without a trigger) when missing, and registered again when its action or its principal is not
|
|
180
|
+
* what this serve would write (a launcher that moved, another install's data folder, a task some other
|
|
181
|
+
* account wrote under this name): Start-ScheduledTask would otherwise run something else, or as someone else.
|
|
182
|
+
* A trigger is kept only on a task that was this user's own
|
|
183
|
+
*
|
|
184
|
+
* A task whose principal is `Interactive` runs in that user's session with their sign-in's token, also when
|
|
185
|
+
* Start-ScheduledTask is called from an ssh session. Measured on a linked Windows 11 PC (2026-10-19): a task started
|
|
186
|
+
* that way from the hub's ssh logon ran in session 1 with an interactive logon at medium integrity and saw the user's
|
|
187
|
+
* Credential Manager entries, where the ssh logon's own processes saw none.
|
|
188
|
+
*
|
|
189
|
+
* `legacy` is the name before the user was part of it (`autostartName`): this user's task of that name is removed by
|
|
190
|
+
* `on`, `off`, `remove` and `signin`, and read by `status`, so an entry an earlier version wrote neither lingers nor
|
|
191
|
+
* starts twice.
|
|
192
|
+
*/
|
|
193
|
+
export function taskScript(action, { name, run = '', legacy = null }) {
|
|
194
|
+
const head = ["$ErrorActionPreference = 'Stop'", PS_MODULE_PATH, '[Console]::OutputEncoding = [Text.Encoding]::UTF8', `$name = ${psText(name)}`]
|
|
195
|
+
const fail = `} catch { "${TASK_WORD}-ERROR $($_.Exception.Message -replace '\\s+', ' ')" }`
|
|
196
|
+
const find = "$t = Get-ScheduledTask -TaskName $name -TaskPath '\\' -ErrorAction SilentlyContinue"
|
|
197
|
+
/*
|
|
198
|
+
* Who this user is: their account's SID, and its name as Windows resolves it (`PC\user`, `DOMAIN\user`), which a task
|
|
199
|
+
* principal is registered with. Never USERDOMAIN: in an ssh logon on a PC in a workgroup it says `WORKGROUP`, which
|
|
200
|
+
* names no account (#1223, measured on a laptop with beta.30). Whether a task's principal is this user's, as this
|
|
201
|
+
* serve registers it, is decided by SID too: Task Scheduler gives the id with the domain, without it, or as a SID
|
|
202
|
+
*/
|
|
203
|
+
const mine = [
|
|
204
|
+
'$account = [System.Security.Principal.WindowsIdentity]::GetCurrent()',
|
|
205
|
+
'$sid = $account.User.Value',
|
|
206
|
+
'$me = $account.Name',
|
|
207
|
+
"function Get-Sid($n) { if (\"$n\" -match '^S-1-') { return \"$n\" }; if (-not \"$n\") { return $null }; try { (New-Object System.Security.Principal.NTAccount(\"$n\")).Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { $null } }",
|
|
208
|
+
"function Test-Mine($x) { $x -and $x.Principal -and ((Get-Sid $x.Principal.UserId) -eq $sid) -and \"$($x.Principal.LogonType)\" -eq 'Interactive' -and \"$($x.Principal.RunLevel)\" -eq 'Limited' }",
|
|
209
|
+
]
|
|
210
|
+
const old = legacy
|
|
211
|
+
? [`$legacy = ${psText(legacy)}`, "$old = Get-ScheduledTask -TaskName $legacy -TaskPath '\\' -ErrorAction SilentlyContinue"]
|
|
212
|
+
: ['$old = $null']
|
|
213
|
+
const dropOld = "if ($old -and (Test-Mine $old)) { Unregister-ScheduledTask -TaskName $legacy -TaskPath '\\' -Confirm:$false }"
|
|
214
|
+
if (action === 'status') {
|
|
215
|
+
return [
|
|
216
|
+
...head,
|
|
217
|
+
'try {',
|
|
218
|
+
...[...mine, ...old, find, 'if (-not $t -and (Test-Mine $old)) { $t = $old }'].map((l) => ` ${l}`),
|
|
219
|
+
` if (-not $t) { '${TASK_WORD} state none' } elseif (@($t.Triggers | Where-Object { $_ }).Count -eq 0) { '${TASK_WORD} state notrigger' } else { "${TASK_WORD} state $($t.State)" }`,
|
|
220
|
+
fail,
|
|
221
|
+
].join('\n')
|
|
222
|
+
}
|
|
223
|
+
if (action === 'remove') {
|
|
224
|
+
return [...head, 'try {', ...[...mine, ...old, find, "if ($t) { Unregister-ScheduledTask -TaskName $name -TaskPath '\\' -Confirm:$false }", dropOld].map((l) => ` ${l}`), ` '${TASK_WORD} off'`, fail].join('\n')
|
|
225
|
+
}
|
|
226
|
+
if (action === 'off') {
|
|
227
|
+
// The task stays, with what it runs and as whom; only the trigger goes
|
|
228
|
+
const keep = "if ($t -and @($t.Triggers | Where-Object { $_ }).Count -gt 0) { Register-ScheduledTask -TaskName $name -TaskPath '\\' -Action $t.Actions -Principal $t.Principal -Settings $t.Settings -Description $t.Description -Force | Out-Null }"
|
|
229
|
+
return [...head, 'try {', ...[...mine, ...old, find, keep, dropOld].map((l) => ` ${l}`), ` '${TASK_WORD} off'`, fail].join('\n')
|
|
230
|
+
}
|
|
231
|
+
const encoded = encodedCommand(run)
|
|
232
|
+
const want = [
|
|
233
|
+
"$exe = Join-Path $env:SystemRoot 'System32\\WindowsPowerShell\\v1.0\\powershell.exe'",
|
|
234
|
+
`$arg = '-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encoded}'`,
|
|
235
|
+
'$user = $me',
|
|
236
|
+
]
|
|
237
|
+
const register = (trigger) => [
|
|
238
|
+
'$a = New-ScheduledTaskAction -Execute $exe -Argument $arg',
|
|
239
|
+
'$p = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited',
|
|
240
|
+
'$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew',
|
|
241
|
+
`$d = ${psText(trigger === 'logon' ? 'Starts the Centralu host (centralu serve) when you sign in. Written by centralu serve --autostart on.' : 'Starts the Centralu host (centralu serve) in your sign-in when a linked computer asks for it. Written by centralu serve --detach; centralu serve --autostart remove removes it.')}`,
|
|
242
|
+
trigger === 'logon'
|
|
243
|
+
? "Register-ScheduledTask -TaskName $name -TaskPath '\\' -Action $a -Trigger (New-ScheduledTaskTrigger -AtLogOn -User $user) -Principal $p -Settings $s -Description $d -Force | Out-Null"
|
|
244
|
+
: "if (@($keep).Count -gt 0) { Register-ScheduledTask -TaskName $name -TaskPath '\\' -Action $a -Trigger $keep -Principal $p -Settings $s -Description $d -Force | Out-Null } else { Register-ScheduledTask -TaskName $name -TaskPath '\\' -Action $a -Principal $p -Settings $s -Description $d -Force | Out-Null }",
|
|
245
|
+
]
|
|
246
|
+
if (action === 'signin') {
|
|
247
|
+
/*
|
|
248
|
+
* Someone is signed in when this user owns an Explorer in a session of its own (not session 0, where an ssh
|
|
249
|
+
* logon's processes run). Asked of WMI for the owner's SID, which needs no administrator for the user's own
|
|
250
|
+
* processes, reads the same in every language, and does not depend on what this logon calls its domain (GetOwner's
|
|
251
|
+
* domain is the PC's name for a local account, while an ssh logon's USERDOMAIN may say `WORKGROUP`). When none is
|
|
252
|
+
* found, how many desktop Explorers there were at all, and how many of their owners WMI would not tell, goes into
|
|
253
|
+
* serve.log's line
|
|
254
|
+
*/
|
|
255
|
+
return [
|
|
256
|
+
...head,
|
|
257
|
+
'try {',
|
|
258
|
+
...mine.map((l) => ` ${l}`),
|
|
259
|
+
" $all = @(Get-CimInstance Win32_Process -Filter \"Name='explorer.exe'\" | Where-Object { $_.SessionId -gt 0 })",
|
|
260
|
+
' $owners = @($all | ForEach-Object { Invoke-CimMethod -InputObject $_ -MethodName GetOwnerSid })',
|
|
261
|
+
' $desk = @($owners | Where-Object { $_.ReturnValue -eq 0 -and $_.Sid -eq $sid })',
|
|
262
|
+
' $unread = @($owners | Where-Object { $_.ReturnValue -ne 0 }).Count',
|
|
263
|
+
` if ($desk.Count -eq 0) { "${TASK_WORD} nosession desktops=$($all.Count) unreadable=$unread" } else {`,
|
|
264
|
+
...[...want, ...old, find, dropOld].map((l) => ` ${l}`),
|
|
265
|
+
' $acts = @(if ($t) { $t.Actions })',
|
|
266
|
+
' $same = (Test-Mine $t) -and $acts.Count -eq 1 -and $acts[0].Execute -eq $exe -and $acts[0].Arguments -eq $arg',
|
|
267
|
+
' if (-not $same) {',
|
|
268
|
+
// A trigger is this user's own choice (autostart on): kept only when the task was theirs
|
|
269
|
+
' $keep = @(if (Test-Mine $t) { $t.Triggers | Where-Object { $_ } })',
|
|
270
|
+
...register('keep').map((l) => ` ${l}`),
|
|
271
|
+
` '${TASK_WORD}-NOTE registered'`,
|
|
272
|
+
' }',
|
|
273
|
+
" Start-ScheduledTask -TaskName $name -TaskPath '\\'",
|
|
274
|
+
` '${TASK_WORD} started'`,
|
|
275
|
+
' }',
|
|
276
|
+
fail,
|
|
277
|
+
].join('\n')
|
|
278
|
+
}
|
|
279
|
+
return [...head, 'try {', ...[...mine, ...want, ...old, ...register('logon'), dropOld].map((l) => ` ${l}`), ` '${TASK_WORD} on'`, fail].join('\n')
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** What a task script printed: `{ ok: true, word }` (`on`, `off`, or the state after `state`) or `{ ok: false, message }` */
|
|
283
|
+
export function parseTaskAnswer(stdout) {
|
|
284
|
+
const text = String(stdout ?? '')
|
|
285
|
+
const e = new RegExp(`${TASK_WORD}-ERROR (.*)`).exec(text)
|
|
286
|
+
if (e) return { ok: false, message: e[1].trim() }
|
|
287
|
+
const m = new RegExp(`${TASK_WORD} (?:state )?(\\S+)`).exec(text)
|
|
288
|
+
if (m) return { ok: true, word: m[1] }
|
|
289
|
+
return { ok: false, message: 'PowerShell did not answer' }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** `{ how, name, linger }` from `AUTOSTART_FILE`, or null */
|
|
293
|
+
export function readAutostartMark(dataDir) {
|
|
294
|
+
try {
|
|
295
|
+
const v = JSON.parse(readFileSync(join(dataDir, AUTOSTART_FILE), 'utf8'))
|
|
296
|
+
if ((v?.how !== 'systemd' && v?.how !== 'task') || typeof v.name !== 'string') return null
|
|
297
|
+
return { how: v.how, name: v.name, linger: typeof v.linger === 'boolean' ? v.linger : null }
|
|
298
|
+
} catch {
|
|
299
|
+
return null
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function writeAutostartMark(dataDir, mark) {
|
|
304
|
+
mkdirSync(dataDir, { recursive: true })
|
|
305
|
+
const file = join(dataDir, AUTOSTART_FILE)
|
|
306
|
+
replaceFile(file, `${JSON.stringify({ v: 1, ...mark, at: Date.now() })}\n`)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function clearAutostartMark(dataDir) {
|
|
310
|
+
rmSync(join(dataDir, AUTOSTART_FILE), { force: true })
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The connection line's `autostart` field (additive): `{ on, how, linger }`. `how` is what this
|
|
315
|
+
* machine would use (null where it offers nothing), `on` whether `--autostart on` wrote an entry of
|
|
316
|
+
* that kind, `linger` (systemd only) whether the user's manager starts at boot. From the mark only:
|
|
317
|
+
* this runs at every link start.
|
|
318
|
+
*/
|
|
319
|
+
export function autostartInfo(dataDir, platform, env, how = autostartHow(platform, env)) {
|
|
320
|
+
const mark = readAutostartMark(dataDir)
|
|
321
|
+
const on = how !== null && mark?.how === how
|
|
322
|
+
return { on, how, linger: on && how === 'systemd' ? mark.linger : null }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The sentence for a user manager that starts only at login (`loginctl enable-linger` refused) */
|
|
326
|
+
export function lingerMessage(user) {
|
|
327
|
+
return `systemd starts it when ${user} logs in, not at boot, until an administrator runs \`sudo loginctl enable-linger ${user}\` there`
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** systemctl --user over ssh needs the user's runtime folder; a session without pam_systemd may not name it */
|
|
331
|
+
function userManagerEnv(env) {
|
|
332
|
+
if (env.XDG_RUNTIME_DIR || typeof process.getuid !== 'function') return env
|
|
333
|
+
const dir = `/run/user/${process.getuid()}`
|
|
334
|
+
return existsSync(dir) ? { ...env, XDG_RUNTIME_DIR: dir } : env
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function systemdAutostart(action, { dataDir, env, home, name, run, user }) {
|
|
338
|
+
const dir = join(env.XDG_CONFIG_HOME || join(home, '.config'), 'systemd', 'user')
|
|
339
|
+
const file = join(dir, name)
|
|
340
|
+
const ctlEnv = userManagerEnv(env)
|
|
341
|
+
const ctl = (...args) => run('systemctl', ['--user', ...args], ctlEnv)
|
|
342
|
+
const lingers = async () => {
|
|
343
|
+
const r = await run('loginctl', ['show-user', user, '--property=Linger', '--value'], ctlEnv)
|
|
344
|
+
return r.code === 0 ? r.stdout.trim() === 'yes' : null
|
|
345
|
+
}
|
|
346
|
+
const base = { ok: true, how: 'systemd' }
|
|
347
|
+
if (action === 'status') {
|
|
348
|
+
const on = existsSync(file) && (await ctl('is-enabled', name)).stdout.trim() === 'enabled'
|
|
349
|
+
const linger = on ? await lingers() : null
|
|
350
|
+
if (on) writeAutostartMark(dataDir, { how: 'systemd', name, linger })
|
|
351
|
+
else clearAutostartMark(dataDir)
|
|
352
|
+
return { ...base, on, linger }
|
|
353
|
+
}
|
|
354
|
+
if (action === 'off' || action === 'remove') {
|
|
355
|
+
if (existsSync(file)) {
|
|
356
|
+
await ctl('disable', name)
|
|
357
|
+
rmSync(file, { force: true })
|
|
358
|
+
// Left by an `enable` whose `disable` did not reach the manager; harmless, but not ours to leave
|
|
359
|
+
rmSync(join(dir, 'default.target.wants', name), { force: true })
|
|
360
|
+
await ctl('daemon-reload')
|
|
361
|
+
}
|
|
362
|
+
clearAutostartMark(dataDir)
|
|
363
|
+
return { ...base, on: false, linger: null }
|
|
364
|
+
}
|
|
365
|
+
const launcher = autostartLauncher(dataDir, 'linux')
|
|
366
|
+
if (!launcher) return { ...base, ok: false, on: false, linger: null, message: `there is no Centralu launcher in ${dataDir} to start at boot` }
|
|
367
|
+
mkdirSync(dir, { recursive: true })
|
|
368
|
+
replaceFile(file, systemdUnit({ launcher, env }))
|
|
369
|
+
let r = await ctl('daemon-reload')
|
|
370
|
+
if (r.code === 0) r = await ctl('enable', name)
|
|
371
|
+
if (r.code !== 0) {
|
|
372
|
+
rmSync(file, { force: true })
|
|
373
|
+
await ctl('daemon-reload')
|
|
374
|
+
clearAutostartMark(dataDir)
|
|
375
|
+
const why = String(r.stderr).trim().split(/\r?\n/).at(-1) || `exit ${r.code}`
|
|
376
|
+
return { ...base, ok: false, on: false, linger: null, message: `systemd did not take the unit: ${why}` }
|
|
377
|
+
}
|
|
378
|
+
let linger = await lingers()
|
|
379
|
+
if (linger === false) {
|
|
380
|
+
await run('loginctl', ['--no-ask-password', 'enable-linger', user], ctlEnv)
|
|
381
|
+
linger = await lingers()
|
|
382
|
+
}
|
|
383
|
+
writeAutostartMark(dataDir, { how: 'systemd', name, linger })
|
|
384
|
+
return { ...base, on: true, linger, ...(linger === true ? {} : { message: lingerMessage(user) }) }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function taskAutostart(action, { dataDir, env, home, name, run, user, platform, interop }) {
|
|
388
|
+
const legacy = legacyTaskName({ dataDir, home, env, platform })
|
|
389
|
+
const detach = detachHow(platform, env, interop)
|
|
390
|
+
const ps = windowsPowershell(detach, env)
|
|
391
|
+
const base = { ok: true, how: 'task', linger: null }
|
|
392
|
+
if (!ps) return { ...base, ok: false, on: false, message: 'Windows PowerShell is not reachable from this WSL distro' }
|
|
393
|
+
const ask = (script) => askPowershell(ps, script, env, parseTaskAnswer, run)
|
|
394
|
+
if (action === 'status') {
|
|
395
|
+
const a = await ask(taskScript('status', { name, legacy }))
|
|
396
|
+
if (!a.ok) return { ...base, ok: false, on: readAutostartMark(dataDir)?.how === 'task', message: `Task Scheduler did not answer: ${a.message}` }
|
|
397
|
+
// A task without its trigger is the one `--detach` starts in the sign-in (#1224): no start at sign-in
|
|
398
|
+
const on = a.word !== 'none' && a.word !== 'Disabled' && a.word !== 'notrigger'
|
|
399
|
+
if (on) writeAutostartMark(dataDir, { how: 'task', name, linger: null })
|
|
400
|
+
else clearAutostartMark(dataDir)
|
|
401
|
+
return { ...base, on }
|
|
402
|
+
}
|
|
403
|
+
if (action === 'off' || action === 'remove') {
|
|
404
|
+
// `off` keeps the task without its trigger, for `--detach`'s start in the sign-in; `remove` (uninstall) removes it
|
|
405
|
+
const a = await ask(taskScript(action, { name, legacy }))
|
|
406
|
+
if (!a.ok) return { ...base, ok: false, on: true, message: `Task Scheduler did not ${action === 'off' ? 'change' : 'remove'} the task "${name}": ${a.message}` }
|
|
407
|
+
clearAutostartMark(dataDir)
|
|
408
|
+
return { ...base, on: false }
|
|
409
|
+
}
|
|
410
|
+
const launcher = autostartLauncher(dataDir, platform)
|
|
411
|
+
if (!launcher) return { ...base, ok: false, on: false, message: `there is no Centralu launcher in ${dataDir} to start at sign-in` }
|
|
412
|
+
const runScript = taskActionScript({ how: detach, launcher, env, distro: env.WSL_DISTRO_NAME ?? null, user })
|
|
413
|
+
const a = await ask(taskScript('on', { name, run: runScript, legacy }))
|
|
414
|
+
if (!a.ok) return { ...base, ok: false, on: false, message: `Task Scheduler did not take the task: ${a.message}` }
|
|
415
|
+
writeAutostartMark(dataDir, { how: 'task', name, linger: null })
|
|
416
|
+
return { ...base, on: true }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Starts this data folder's host in the person's Windows sign-in, when someone is signed in (#1224): the task
|
|
421
|
+
* `--autostart` writes, registered without its trigger when it is not there, started with Start-ScheduledTask. Called
|
|
422
|
+
* by `serve --detach` before it falls back to WMI, whose process keeps the caller's logon: from an ssh session that is a
|
|
423
|
+
* network logon, which cannot open the user's Credential Manager vault, runs elevated for an administrator (#702) and
|
|
424
|
+
* opens what it shows in session 0, where nobody sees it.
|
|
425
|
+
*
|
|
426
|
+
* `{ started: true, name }`, or `{ started: false, reason, message?, desktops? }`: `nosession` (this user is not signed
|
|
427
|
+
* in here; `desktops` is how many desktop Explorers, of any account, there were, `unreadable` how many of their owners
|
|
428
|
+
* WMI would not tell),
|
|
429
|
+
* `off` (`CENTRALU_SIGNIN_TASK=off`, for a person who wants no task, and for tests on a machine with a desktop),
|
|
430
|
+
* `not_windows`, `no_launcher`, `error`. `run` is a parameter so tests never reach the real Task Scheduler.
|
|
431
|
+
*/
|
|
432
|
+
export async function startInSignIn({ dataDir, env, home, platform = process.platform, run = runCaptured, user = env.USERNAME || userInfo().username }) {
|
|
433
|
+
if (platform !== 'win32' || env.WSL_DISTRO_NAME) return { started: false, reason: 'not_windows' }
|
|
434
|
+
if (env.CENTRALU_SIGNIN_TASK === 'off') return { started: false, reason: 'off' }
|
|
435
|
+
const launcher = autostartLauncher(dataDir, platform)
|
|
436
|
+
if (!launcher) return { started: false, reason: 'no_launcher' }
|
|
437
|
+
const ps = windowsPowershell('wmi', env)
|
|
438
|
+
if (!ps) return { started: false, reason: 'error', message: 'Windows PowerShell is not reachable' }
|
|
439
|
+
const name = autostartName('task', { dataDir, home, env, platform, user })
|
|
440
|
+
const runScript = taskActionScript({ how: 'wmi', launcher, env })
|
|
441
|
+
const parse = (out) => {
|
|
442
|
+
const m = /nosession desktops=(\d+)(?: unreadable=(\d+))?/.exec(String(out ?? ''))
|
|
443
|
+
return { ...parseTaskAnswer(out), ...(m ? { desktops: Number(m[1]), unreadable: Number(m[2] ?? 0) } : {}) }
|
|
444
|
+
}
|
|
445
|
+
const a = await askPowershell(ps, taskScript('signin', { name, run: runScript, legacy: legacyTaskName({ dataDir, home, env, platform }) }), env, parse, run)
|
|
446
|
+
if (!a.ok) return { started: false, reason: 'error', message: a.message }
|
|
447
|
+
if (a.word === 'started') return { started: true, name }
|
|
448
|
+
if (a.word === 'nosession') return { started: false, reason: 'nosession', ...(a.desktops !== undefined ? { desktops: a.desktops, unreadable: a.unreadable } : {}) }
|
|
449
|
+
return { started: false, reason: 'error', message: `Task Scheduler answered ${a.word}` }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* `centralu serve --autostart on|off|remove|status` (docs/plans/remote-hub.md §10.4, owner decision 4: per
|
|
454
|
+
* machine, off by default). The hub runs it over ssh for the Settings → Machines toggle, and its
|
|
455
|
+
* uninstall runs `remove` (`off` on a serve that predates it). Turning it on or off never starts or stops a host: one runs already when the
|
|
456
|
+
* hub asks (the link started it), and the entry is for the next boot.
|
|
457
|
+
*
|
|
458
|
+
* stdout: one line, `{ v: 1, autostart: { ok, on, how, linger, message? } }`; `how` null where this
|
|
459
|
+
* machine offers nothing (`autostartHow`). `run` (a command runner) and `how` are parameters so
|
|
460
|
+
* tests never reach the real systemd or Task Scheduler.
|
|
461
|
+
*/
|
|
462
|
+
export async function runAutostart({
|
|
463
|
+
env,
|
|
464
|
+
home,
|
|
465
|
+
action,
|
|
466
|
+
cliPath,
|
|
467
|
+
platform = process.platform,
|
|
468
|
+
interop = wslInterop,
|
|
469
|
+
how = autostartHow(platform, env, { interop }),
|
|
470
|
+
run = runCaptured,
|
|
471
|
+
user = userInfo().username,
|
|
472
|
+
write = (line) => process.stdout.write(line),
|
|
473
|
+
}) {
|
|
474
|
+
const dataDir = serveDataDir(env, home)
|
|
475
|
+
// The entry of an npm install runs the launcher serve keeps; make sure it is there and current
|
|
476
|
+
keepLauncher(dataDir, cliPath, env)
|
|
477
|
+
const report = (a) => answerLine(write, 'autostart', a)
|
|
478
|
+
if (how === null) {
|
|
479
|
+
clearAutostartMark(dataDir)
|
|
480
|
+
return report({ ok: action !== 'on', on: false, how: null, linger: null, ...(action === 'off' || action === 'remove' ? {} : { message: autostartUnsupported(platform, env) }) })
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
const name = autostartName(how, { dataDir, home, env, platform, user })
|
|
484
|
+
const opts = { dataDir, env, home, name, run, user, platform, interop }
|
|
485
|
+
return report(how === 'systemd' ? await systemdAutostart(action, opts) : await taskAutostart(action, opts))
|
|
486
|
+
} catch (e) {
|
|
487
|
+
return report({ ok: false, on: readAutostartMark(dataDir)?.how === how, how, linger: null, message: e?.message ?? String(e) })
|
|
488
|
+
}
|
|
489
|
+
}
|