centralu 0.1.0-beta.26 → 0.1.0-beta.27

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/platform.mjs CHANGED
@@ -200,7 +200,8 @@ export function installedPaths(platform, env, home) {
200
200
  return [posix.join(home, '.local/share/applications/centralu.desktop')]
201
201
  }
202
202
 
203
- function systemRoot(env) {
203
+ /** The Windows folder (`%SystemRoot%`), where Windows' own programs are named from */
204
+ export function systemRoot(env) {
204
205
  return env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows'
205
206
  }
206
207
 
@@ -0,0 +1,97 @@
1
+ /** The arguments after `centralu serve`, and its help */
2
+ import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
3
+
4
+ export const SERVE_HELP = `centralu serve: run the Centralu host on this machine, without a window
5
+
6
+ centralu serve Start the host in the foreground, on 127.0.0.1 only
7
+ centralu serve --port <n> ... on that port (kept for the next start; default ${DEFAULT_SERVE_PORT})
8
+ centralu serve --detach Start it in the background, outside this session; return once it answers
9
+ centralu serve --stop Ask the running host to stop, and wait until it has
10
+ centralu serve --connection Print how to reach it as one JSON line, then exit
11
+ centralu serve --rotate-token Replace the token (a running serve keeps the old one until restarted)
12
+ centralu serve --autostart on|off|status
13
+ Start it when this machine starts (Linux: a systemd user unit;
14
+ Windows and WSL: a scheduled task at sign-in), stop doing that,
15
+ or say whether it does. Turning it on or off leaves a host that
16
+ runs now as it is
17
+
18
+ The data folder is ~/.centralu (CC_DATA_DIR overrides it). The app on another computer reaches
19
+ this host through an SSH local forward, and starts it with --detach when it is not running.
20
+ Stop a foreground one with Ctrl+C or SIGTERM, a detached one with --stop. A detached one writes
21
+ how its start went to serve.log in the data folder; the host's own log is host.log.
22
+
23
+ serve, --detach and --connection keep ~/.centralu/bin/centralu up to date: a launcher with absolute
24
+ paths, for SSH sessions whose PATH has neither npm's global folder nor node. Not when they run
25
+ from an install the app made over SSH, which has a launcher of its own.`
26
+
27
+ /** The flags that each make serve a different command */
28
+ const MODE_FLAGS = { '--connection': 'connection', '--rotate-token': 'rotate', '--detach': 'detach', '--stop': 'stop' }
29
+ const FLAG_OF = { ...Object.fromEntries(Object.entries(MODE_FLAGS).map(([f, m]) => [m, f])), autostart: '--autostart' }
30
+
31
+ /** What `--autostart` takes */
32
+ export const AUTOSTART_ACTIONS = ['on', 'off', 'status']
33
+
34
+ /**
35
+ * The argument a detached launcher is started with: what it needs from the `--detach` that started
36
+ * it, which a process created through WMI does not inherit (it gets the account's default
37
+ * environment, not this one). Never a secret: the token stays in serve.json. Internal, not in the help.
38
+ */
39
+ export const CHILD_FLAG = '--detached-child='
40
+
41
+ /**
42
+ * What the arguments after `serve` ask for: `{ mode, port }` (plus `child` for a detached launcher,
43
+ * `autostart` for `--autostart`) or `{ error }`.
44
+ *
45
+ * `mode` is `serve`, `detach`, `stop`, `connection`, `rotate`, `autostart` or `help`; `port` is null
46
+ * when not given. `--port` goes with `serve` and `--detach` only: `--connection` has to report the
47
+ * port `serve` uses, not one the caller names, `--stop` stops the one that runs, and the entry
48
+ * `--autostart` writes starts serve on the port it recorded.
49
+ */
50
+ export function parseServeArgs(argv) {
51
+ let mode = 'serve'
52
+ let port = null
53
+ let help = false
54
+ let child = null
55
+ let autostart = null
56
+ for (let i = 0; i < argv.length; i++) {
57
+ const a = argv[i]
58
+ if (a === '--autostart' || a.startsWith('--autostart=')) {
59
+ const raw = a === '--autostart' ? argv[++i] : a.slice('--autostart='.length)
60
+ if (!AUTOSTART_ACTIONS.includes(raw)) return { error: `--autostart needs on, off or status (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
61
+ if (mode !== 'serve' && mode !== 'autostart') return { error: `${FLAG_OF[mode]} and --autostart are separate commands; pass one` }
62
+ mode = 'autostart'
63
+ autostart = raw
64
+ } else if (Object.hasOwn(MODE_FLAGS, a)) {
65
+ const m = MODE_FLAGS[a]
66
+ if (mode !== 'serve' && mode !== m) return { error: `${FLAG_OF[mode]} and ${a} are separate commands; pass one` }
67
+ mode = m
68
+ } else if (a === '--help' || a === '-h') help = true
69
+ else if (a.startsWith(CHILD_FLAG)) child = a.slice(CHILD_FLAG.length)
70
+ else if (a === '--port' || a.startsWith('--port=')) {
71
+ const raw = a === '--port' ? argv[++i] : a.slice('--port='.length)
72
+ const n = parsePort(raw)
73
+ if (n === null) return { error: `--port needs a port number between 1 and 65535 (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
74
+ port = n
75
+ } else return { error: `unknown option for serve: ${a}\n\n${SERVE_HELP}` }
76
+ }
77
+ if (help) return { mode: 'help', port }
78
+ if (child !== null) {
79
+ if (mode !== 'serve') return { error: `${CHILD_FLAG.slice(0, -1)} is how --detach starts serve, not an option of ${FLAG_OF[mode]}` }
80
+ return { mode, port, child }
81
+ }
82
+ if ((mode === 'connection' || mode === 'rotate') && port !== null) {
83
+ return { error: '--port goes with `centralu serve`; `--connection` reports the port serve listens on' }
84
+ }
85
+ if (mode === 'stop' && port !== null) return { error: '--stop stops the serve that is running, on the port it recorded; it takes no --port' }
86
+ if (mode === 'autostart') {
87
+ if (port !== null) return { error: '--autostart takes no --port: the entry it writes starts serve on the port serve recorded' }
88
+ return { mode, port, autostart }
89
+ }
90
+ return { mode, port }
91
+ }
92
+
93
+ function parsePort(raw) {
94
+ if (typeof raw !== 'string' || !/^\d{1,5}$/.test(raw)) return null
95
+ const n = Number(raw)
96
+ return n >= 1 && n <= 65535 ? n : null
97
+ }
@@ -0,0 +1,358 @@
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 }) {
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
+ return `Centralu host${distro ? ` (WSL ${distro})` : ''}${own ? ` ${own}` : ''}`
78
+ }
79
+
80
+ /**
81
+ * What the entry runs: the managed launcher when the app installed Centralu here over ssh (its text
82
+ * never changes and it reads `current`, so an update needs no new entry), else the launcher `serve`
83
+ * keeps for an npm install. The order is the hub's lookup (`connectionCommand` in links/tunnel.ts).
84
+ */
85
+ export function autostartLauncher(dataDir, platform, exists = existsSync) {
86
+ const file = platform === 'win32' ? 'centralu.cmd' : 'centralu'
87
+ for (const p of [join(remoteLayout(dataDir).bin, file), join(dataDir, 'bin', file)]) if (exists(p)) return p
88
+ return null
89
+ }
90
+
91
+ /** One value for a unit file, double-quoted; `$` doubled where systemd would expand it (ExecStart) */
92
+ function systemdQuote(value, { dollars = false } = {}) {
93
+ const s = String(value)
94
+ if (/[\r\n\0]/.test(s)) throw new Error(`cannot put ${JSON.stringify(s)} in a systemd unit`)
95
+ let q = s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')
96
+ if (dollars) q = q.replaceAll('$', '$$$$')
97
+ return `"${q}"`
98
+ }
99
+
100
+ /**
101
+ * The `systemd --user` unit (docs/agent-host.md §4.7 has the hand-written one this follows).
102
+ *
103
+ * - `serve` in the foreground: systemd is what keeps it outside any session.
104
+ * - `KillMode=mixed`: a stop sends SIGTERM to the launcher only, which passes one to the host, which
105
+ * stops its agents in order; the rest of the group is killed only after `TimeoutStopSec`.
106
+ * - `Restart=on-failure`, but bounded: a host that fails at every start (a port another program
107
+ * holds, a store from a newer build) is left failed after five tries in five minutes rather than
108
+ * started forever. A clean stop (`serve --stop`, as an update does) exits 0 and stays stopped.
109
+ */
110
+ export function systemdUnit({ launcher, env }) {
111
+ return [
112
+ '# Written by `centralu serve --autostart on`; `centralu serve --autostart off` removes it.',
113
+ '[Unit]',
114
+ 'Description=Centralu host (centralu serve)',
115
+ 'StartLimitIntervalSec=300',
116
+ 'StartLimitBurst=5',
117
+ '',
118
+ '[Service]',
119
+ 'Type=simple',
120
+ ...AUTOSTART_ENV.filter((k) => env[k]).map((k) => `Environment=${systemdQuote(`${k}=${env[k]}`)}`),
121
+ `ExecStart=${systemdQuote(launcher, { dollars: true })} serve`,
122
+ 'KillMode=mixed',
123
+ 'TimeoutStopSec=30',
124
+ 'Restart=on-failure',
125
+ 'RestartSec=10',
126
+ '',
127
+ '[Install]',
128
+ 'WantedBy=default.target',
129
+ '',
130
+ ].join('\n')
131
+ }
132
+
133
+ /**
134
+ * The PowerShell the scheduled task runs at sign-in, in a hidden window: `serve` in the foreground,
135
+ * for as long as it runs. Not `--detach`: a task is not an ssh session, so nothing ends it with one,
136
+ * and it needs no WMI, which some machines block (decision 3).
137
+ *
138
+ * Windows the launcher itself
139
+ * WSL `wsl.exe -d <distro> -u <user> --exec <launcher> serve`: that `wsl.exe` is a client of
140
+ * the distro for as long as the host runs, so WSL does not stop it (§10.4)
141
+ *
142
+ * The variables of `AUTOSTART_ENV` are set in the task (Windows) or passed through `env` (WSL, where
143
+ * the Windows side's environment does not reach the distro).
144
+ */
145
+ export function taskActionScript({ how, launcher, env, distro = null, user = null }) {
146
+ const carried = AUTOSTART_ENV.filter((k) => env[k])
147
+ if (how === 'wmi') return [...carried.map((k) => `$env:${k} = ${psText(env[k])}`), `& ${psText(launcher)} serve`].join('\n')
148
+ const args = ['-d', wslDistro(distro), '-u', wslUser(user), '--cd', '~', '--exec', ...(carried.length ? ['/usr/bin/env', ...carried.map((k) => `${k}=${env[k]}`)] : []), launcher, 'serve']
149
+ // PowerShell writes the command line itself; what windowsArg could not quote, it would not either
150
+ for (const a of args) windowsArg(a)
151
+ return [`$a = @(${args.map(psText).join(', ')})`, "& (Join-Path $env:SystemRoot 'System32\\wsl.exe') @a"].join('\n')
152
+ }
153
+
154
+ const TASK_WORD = 'CENTRALU-AUTOSTART'
155
+
156
+ /**
157
+ * The PowerShell that registers, removes or reads the scheduled task, printing one `CENTRALU-AUTOSTART`
158
+ * line. Registered for this user only, at this user's sign-in, run only while they are signed in
159
+ * (`Interactive`): the kind of task a user may create without an administrator. On a laptop, it runs
160
+ * on battery too, and with no time limit (the default ends a task after 72 hours).
161
+ */
162
+ export function taskScript(action, { name, run = '' }) {
163
+ const head = ["$ErrorActionPreference = 'Stop'", PS_MODULE_PATH, '[Console]::OutputEncoding = [Text.Encoding]::UTF8', `$name = ${psText(name)}`]
164
+ const fail = `} catch { "${TASK_WORD}-ERROR $($_.Exception.Message -replace '\\s+', ' ')" }`
165
+ const find = "$t = Get-ScheduledTask -TaskName $name -TaskPath '\\' -ErrorAction SilentlyContinue"
166
+ if (action === 'status') return [...head, 'try {', ` ${find}`, ` if ($t) { "${TASK_WORD} state $($t.State)" } else { '${TASK_WORD} state none' }`, fail].join('\n')
167
+ if (action === 'off') {
168
+ return [...head, 'try {', ` ${find}`, " if ($t) { Unregister-ScheduledTask -TaskName $name -TaskPath '\\' -Confirm:$false }", ` '${TASK_WORD} off'`, fail].join('\n')
169
+ }
170
+ const encoded = encodedCommand(run)
171
+ return [
172
+ ...head,
173
+ 'try {',
174
+ " $exe = Join-Path $env:SystemRoot 'System32\\WindowsPowerShell\\v1.0\\powershell.exe'",
175
+ ` $arg = '-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encoded}'`,
176
+ ' $user = if ($env:USERDOMAIN) { "$env:USERDOMAIN\\$env:USERNAME" } else { $env:USERNAME }',
177
+ ' $a = New-ScheduledTaskAction -Execute $exe -Argument $arg',
178
+ ' $tr = New-ScheduledTaskTrigger -AtLogOn -User $user',
179
+ ' $p = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited',
180
+ ' $s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew',
181
+ ` Register-ScheduledTask -TaskName $name -TaskPath '\\' -Action $a -Trigger $tr -Principal $p -Settings $s -Description ${psText('Starts the Centralu host (centralu serve) when you sign in. Written by centralu serve --autostart on.')} -Force | Out-Null`,
182
+ ` '${TASK_WORD} on'`,
183
+ fail,
184
+ ].join('\n')
185
+ }
186
+
187
+ /** What a task script printed: `{ ok: true, word }` (`on`, `off`, or the state after `state`) or `{ ok: false, message }` */
188
+ export function parseTaskAnswer(stdout) {
189
+ const text = String(stdout ?? '')
190
+ const e = new RegExp(`${TASK_WORD}-ERROR (.*)`).exec(text)
191
+ if (e) return { ok: false, message: e[1].trim() }
192
+ const m = new RegExp(`${TASK_WORD} (?:state )?(\\S+)`).exec(text)
193
+ if (m) return { ok: true, word: m[1] }
194
+ return { ok: false, message: 'PowerShell did not answer' }
195
+ }
196
+
197
+ /** `{ how, name, linger }` from `AUTOSTART_FILE`, or null */
198
+ export function readAutostartMark(dataDir) {
199
+ try {
200
+ const v = JSON.parse(readFileSync(join(dataDir, AUTOSTART_FILE), 'utf8'))
201
+ if ((v?.how !== 'systemd' && v?.how !== 'task') || typeof v.name !== 'string') return null
202
+ return { how: v.how, name: v.name, linger: typeof v.linger === 'boolean' ? v.linger : null }
203
+ } catch {
204
+ return null
205
+ }
206
+ }
207
+
208
+ function writeAutostartMark(dataDir, mark) {
209
+ mkdirSync(dataDir, { recursive: true })
210
+ const file = join(dataDir, AUTOSTART_FILE)
211
+ replaceFile(file, `${JSON.stringify({ v: 1, ...mark, at: Date.now() })}\n`)
212
+ }
213
+
214
+ function clearAutostartMark(dataDir) {
215
+ rmSync(join(dataDir, AUTOSTART_FILE), { force: true })
216
+ }
217
+
218
+ /**
219
+ * The connection line's `autostart` field (additive): `{ on, how, linger }`. `how` is what this
220
+ * machine would use (null where it offers nothing), `on` whether `--autostart on` wrote an entry of
221
+ * that kind, `linger` (systemd only) whether the user's manager starts at boot. From the mark only:
222
+ * this runs at every link start.
223
+ */
224
+ export function autostartInfo(dataDir, platform, env, how = autostartHow(platform, env)) {
225
+ const mark = readAutostartMark(dataDir)
226
+ const on = how !== null && mark?.how === how
227
+ return { on, how, linger: on && how === 'systemd' ? mark.linger : null }
228
+ }
229
+
230
+ /** The sentence for a user manager that starts only at login (`loginctl enable-linger` refused) */
231
+ export function lingerMessage(user) {
232
+ return `systemd starts it when ${user} logs in, not at boot, until an administrator runs \`sudo loginctl enable-linger ${user}\` there`
233
+ }
234
+
235
+ /** systemctl --user over ssh needs the user's runtime folder; a session without pam_systemd may not name it */
236
+ function userManagerEnv(env) {
237
+ if (env.XDG_RUNTIME_DIR || typeof process.getuid !== 'function') return env
238
+ const dir = `/run/user/${process.getuid()}`
239
+ return existsSync(dir) ? { ...env, XDG_RUNTIME_DIR: dir } : env
240
+ }
241
+
242
+ async function systemdAutostart(action, { dataDir, env, home, name, run, user }) {
243
+ const dir = join(env.XDG_CONFIG_HOME || join(home, '.config'), 'systemd', 'user')
244
+ const file = join(dir, name)
245
+ const ctlEnv = userManagerEnv(env)
246
+ const ctl = (...args) => run('systemctl', ['--user', ...args], ctlEnv)
247
+ const lingers = async () => {
248
+ const r = await run('loginctl', ['show-user', user, '--property=Linger', '--value'], ctlEnv)
249
+ return r.code === 0 ? r.stdout.trim() === 'yes' : null
250
+ }
251
+ const base = { ok: true, how: 'systemd' }
252
+ if (action === 'status') {
253
+ const on = existsSync(file) && (await ctl('is-enabled', name)).stdout.trim() === 'enabled'
254
+ const linger = on ? await lingers() : null
255
+ if (on) writeAutostartMark(dataDir, { how: 'systemd', name, linger })
256
+ else clearAutostartMark(dataDir)
257
+ return { ...base, on, linger }
258
+ }
259
+ if (action === 'off') {
260
+ if (existsSync(file)) {
261
+ await ctl('disable', name)
262
+ rmSync(file, { force: true })
263
+ // Left by an `enable` whose `disable` did not reach the manager; harmless, but not ours to leave
264
+ rmSync(join(dir, 'default.target.wants', name), { force: true })
265
+ await ctl('daemon-reload')
266
+ }
267
+ clearAutostartMark(dataDir)
268
+ return { ...base, on: false, linger: null }
269
+ }
270
+ const launcher = autostartLauncher(dataDir, 'linux')
271
+ if (!launcher) return { ...base, ok: false, on: false, linger: null, message: `there is no Centralu launcher in ${dataDir} to start at boot` }
272
+ mkdirSync(dir, { recursive: true })
273
+ replaceFile(file, systemdUnit({ launcher, env }))
274
+ let r = await ctl('daemon-reload')
275
+ if (r.code === 0) r = await ctl('enable', name)
276
+ if (r.code !== 0) {
277
+ rmSync(file, { force: true })
278
+ await ctl('daemon-reload')
279
+ clearAutostartMark(dataDir)
280
+ const why = String(r.stderr).trim().split(/\r?\n/).at(-1) || `exit ${r.code}`
281
+ return { ...base, ok: false, on: false, linger: null, message: `systemd did not take the unit: ${why}` }
282
+ }
283
+ let linger = await lingers()
284
+ if (linger === false) {
285
+ await run('loginctl', ['--no-ask-password', 'enable-linger', user], ctlEnv)
286
+ linger = await lingers()
287
+ }
288
+ writeAutostartMark(dataDir, { how: 'systemd', name, linger })
289
+ return { ...base, on: true, linger, ...(linger === true ? {} : { message: lingerMessage(user) }) }
290
+ }
291
+
292
+ async function taskAutostart(action, { dataDir, env, name, run, user, platform, interop }) {
293
+ const detach = detachHow(platform, env, interop)
294
+ const ps = windowsPowershell(detach, env)
295
+ const base = { ok: true, how: 'task', linger: null }
296
+ if (!ps) return { ...base, ok: false, on: false, message: 'Windows PowerShell is not reachable from this WSL distro' }
297
+ const ask = (script) => askPowershell(ps, script, env, parseTaskAnswer, run)
298
+ if (action === 'status') {
299
+ const a = await ask(taskScript('status', { name }))
300
+ if (!a.ok) return { ...base, ok: false, on: readAutostartMark(dataDir)?.how === 'task', message: `Task Scheduler did not answer: ${a.message}` }
301
+ const on = a.word !== 'none' && a.word !== 'Disabled'
302
+ if (on) writeAutostartMark(dataDir, { how: 'task', name, linger: null })
303
+ else clearAutostartMark(dataDir)
304
+ return { ...base, on }
305
+ }
306
+ if (action === 'off') {
307
+ const a = await ask(taskScript('off', { name }))
308
+ if (!a.ok) return { ...base, ok: false, on: true, message: `Task Scheduler did not remove the task "${name}": ${a.message}` }
309
+ clearAutostartMark(dataDir)
310
+ return { ...base, on: false }
311
+ }
312
+ const launcher = autostartLauncher(dataDir, platform)
313
+ if (!launcher) return { ...base, ok: false, on: false, message: `there is no Centralu launcher in ${dataDir} to start at sign-in` }
314
+ const runScript = taskActionScript({ how: detach, launcher, env, distro: env.WSL_DISTRO_NAME ?? null, user })
315
+ const a = await ask(taskScript('on', { name, run: runScript }))
316
+ if (!a.ok) return { ...base, ok: false, on: false, message: `Task Scheduler did not take the task: ${a.message}` }
317
+ writeAutostartMark(dataDir, { how: 'task', name, linger: null })
318
+ return { ...base, on: true }
319
+ }
320
+
321
+ /**
322
+ * `centralu serve --autostart on|off|status` (docs/plans/remote-hub.md §10.4, owner decision 4: per
323
+ * machine, off by default). The hub runs it over ssh for the Settings → Machines toggle, and its
324
+ * uninstall runs `off`. Turning it on or off never starts or stops a host: one runs already when the
325
+ * hub asks (the link started it), and the entry is for the next boot.
326
+ *
327
+ * stdout: one line, `{ v: 1, autostart: { ok, on, how, linger, message? } }`; `how` null where this
328
+ * machine offers nothing (`autostartHow`). `run` (a command runner) and `how` are parameters so
329
+ * tests never reach the real systemd or Task Scheduler.
330
+ */
331
+ export async function runAutostart({
332
+ env,
333
+ home,
334
+ action,
335
+ cliPath,
336
+ platform = process.platform,
337
+ interop = wslInterop,
338
+ how = autostartHow(platform, env, { interop }),
339
+ run = runCaptured,
340
+ user = userInfo().username,
341
+ write = (line) => process.stdout.write(line),
342
+ }) {
343
+ const dataDir = serveDataDir(env, home)
344
+ // The entry of an npm install runs the launcher serve keeps; make sure it is there and current
345
+ keepLauncher(dataDir, cliPath, env)
346
+ const report = (a) => answerLine(write, 'autostart', a)
347
+ if (how === null) {
348
+ clearAutostartMark(dataDir)
349
+ return report({ ok: action !== 'on', on: false, how: null, linger: null, ...(action === 'off' ? {} : { message: autostartUnsupported(platform, env) }) })
350
+ }
351
+ try {
352
+ const name = autostartName(how, { dataDir, home, env, platform })
353
+ const opts = { dataDir, env, home, name, run, user, platform, interop }
354
+ return report(how === 'systemd' ? await systemdAutostart(action, opts) : await taskAutostart(action, opts))
355
+ } catch (e) {
356
+ return report({ ok: false, on: readAutostartMark(dataDir)?.how === how, how, linger: null, message: e?.message ?? String(e) })
357
+ }
358
+ }