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.
@@ -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
+ }