centralu 0.1.0-beta.2 → 0.1.0-beta.21

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/serve.mjs ADDED
@@ -0,0 +1,1709 @@
1
+ /**
2
+ * `centralu serve`: the host without the app, for the remote mode's first phase (#82).
3
+ *
4
+ * The person starts this by hand on another machine (an SSH server, later a laptop). It runs the
5
+ * same bundled host the app runs (`resources/host/main.mjs`, on the system Node), with no window
6
+ * and no keeper, bound to 127.0.0.1 only. The app on the person's own computer reaches it through
7
+ * an SSH local forward, so nothing ever listens on a public interface. The design and its limits
8
+ * are in docs/agent-host.md §4.7; the trust argument in docs/security-boundaries.md.
9
+ *
10
+ * `centralu serve --connection` is the one question the client asks over `ssh <target>`: where to
11
+ * forward and how to authenticate. It answers with one JSON line on stdout and nothing else.
12
+ *
13
+ * Everything here takes its inputs as arguments (environment, home, platform) instead of reading
14
+ * `process`, so `tooling/launcher-serve.test.ts` can import it: `centralu.mjs` runs a command at
15
+ * import time and cannot be imported by a test. package.json "files" lists the whole bin/
16
+ * directory, so this ships automatically.
17
+ */
18
+ import { spawn } from 'node:child_process'
19
+ import { createHash, randomBytes } from 'node:crypto'
20
+ import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync, closeSync } from 'node:fs'
21
+ import { connect, createServer } from 'node:net'
22
+ import { userInfo } from 'node:os'
23
+ import { dirname, join, posix } from 'node:path'
24
+ import { createInterface } from 'node:readline'
25
+
26
+ /**
27
+ * The port `serve` listens on unless told otherwise, and keeps across restarts.
28
+ *
29
+ * Stable, because the client is told it once per connection and the person may write it into an
30
+ * SSH config or a systemd unit. Below 20000 on purpose: every OS lends ports from its ephemeral
31
+ * range to outgoing connections (Linux 32768–60999, macOS and Windows 49152–65535), and the host
32
+ * itself hands out per-app view origins from 20000–32767 (views/origin-ports.ts). A default inside
33
+ * either range could be taken by something transient the day the host restarts.
34
+ */
35
+ export const DEFAULT_SERVE_PORT = 17175
36
+
37
+ /** How many ports from the recorded one `serve` tries when it is taken (#482); none at or above 20000 */
38
+ export const SERVE_PORT_TRIES = 20
39
+
40
+ /**
41
+ * The port `serve` starts the host on (#482): `{ port, from }`, `from` being the port it moved away
42
+ * from (null when it did not), or null when every candidate is taken.
43
+ *
44
+ * A port the person gave (`--port`) is used as given: they asked for that one. Otherwise the recorded
45
+ * port (else the default) when it is free, and when another program listens there, the next free
46
+ * one above it, below 20000 (the ranges `DEFAULT_SERVE_PORT` keeps out of). The host's ready line
47
+ * then records the port it got, so `--connection` reports it and the hub forwards to it.
48
+ *
49
+ * The usual other program is the other half of one Windows laptop: WSL2 relays a distro's
50
+ * 127.0.0.1 ports to Windows (`wslrelay.exe`), so a Windows install and a WSL install linked as two
51
+ * machines both wanted 17175, and whichever started second could not start at all.
52
+ * `taken(port)` answers whether something listens there.
53
+ */
54
+ export async function chooseServePort({ asked, recorded, taken }) {
55
+ if (asked !== null && asked !== undefined) return { port: asked, from: null }
56
+ const first = recorded ?? DEFAULT_SERVE_PORT
57
+ for (let p = first; p < first + SERVE_PORT_TRIES && p <= 65535; p++) {
58
+ if (p !== first && p >= 20000) break
59
+ if (!(await taken(p))) return { port: p, from: p === first ? null : first }
60
+ }
61
+ return null
62
+ }
63
+
64
+ /** Whether something listens on 127.0.0.1:`port` here: a listen that fails with EADDRINUSE (or EACCES) */
65
+ export function listensHere(port) {
66
+ return new Promise((resolve) => {
67
+ const srv = createServer()
68
+ srv.once('error', (e) => resolve(e?.code === 'EADDRINUSE' || e?.code === 'EACCES'))
69
+ srv.listen({ host: '127.0.0.1', port, exclusive: true }, () => srv.close(() => resolve(false)))
70
+ })
71
+ }
72
+
73
+ /**
74
+ * The TCP ports Windows listens on, from `netstat.exe -an -p TCP` output: a listening socket is the
75
+ * row whose foreign address is `0.0.0.0:0` (or `[::]:0`). Read that way rather than by the state
76
+ * column, which Windows translates (a Korean Windows does not print LISTENING everywhere).
77
+ */
78
+ export function parseNetstatListening(text) {
79
+ const ports = new Set()
80
+ for (const line of String(text).split(/\r?\n/)) {
81
+ const m = /^\s*TCP\s+(?:127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d+)\s+(?:0\.0\.0\.0:0|\[::\]:0)\s/i.exec(line)
82
+ if (m) ports.add(Number(m[1]))
83
+ }
84
+ return ports
85
+ }
86
+
87
+ /**
88
+ * The ports the Windows side listens on, seen from inside a WSL distro (empty elsewhere, or when
89
+ * Windows cannot be asked). Inside the distro a port Windows holds looks free, yet a host there
90
+ * would be unreachable from the hub: the hub's forward ends on Windows' 127.0.0.1, where the
91
+ * Windows program answers instead (#482).
92
+ */
93
+ async function windowsListening(env, platform) {
94
+ if (detachHow(platform, env) !== 'wsl') return new Set()
95
+ const netstat = windowsTool('wsl', env, ['System32', 'netstat.exe'])
96
+ if (!netstat) return new Set()
97
+ const r = await runCaptured(netstat, ['-an', '-p', 'TCP'], env)
98
+ return r.code === 0 ? parseNetstatListening(r.stdout) : new Set()
99
+ }
100
+
101
+ /**
102
+ * The file in the data folder that holds the token and the port, mode 0600.
103
+ *
104
+ * The token is generated once, the first time either `serve` or `serve --connection` runs, and kept:
105
+ * the client stores it (in the OS secret store) and has to find the same one after the host
106
+ * restarts. The port is the one the last `serve` actually listened on, which is how
107
+ * `--connection`, a separate process, learns a `--port` given to `serve` in a systemd unit.
108
+ */
109
+ export const SERVE_STATE_FILE = 'serve.json'
110
+
111
+ /** What a detached serve writes: its own lines, and the host's until it is ready (host.log has the rest) */
112
+ export const SERVE_LOG_FILE = 'serve.log'
113
+
114
+ /** The running launcher's pid and port, so `--stop` can find it. Removed when it exits */
115
+ export const SERVE_PID_FILE = 'serve.pid'
116
+
117
+ export const SERVE_HELP = `centralu serve: run the Centralu host on this machine, without a window
118
+
119
+ centralu serve Start the host in the foreground, on 127.0.0.1 only
120
+ centralu serve --port <n> ... on that port (kept for the next start; default ${DEFAULT_SERVE_PORT})
121
+ centralu serve --detach Start it in the background, outside this session; return once it answers
122
+ centralu serve --stop Ask the running host to stop, and wait until it has
123
+ centralu serve --connection Print how to reach it as one JSON line, then exit
124
+ centralu serve --rotate-token Replace the token (a running serve keeps the old one until restarted)
125
+ centralu serve --autostart on|off|status
126
+ Start it when this machine starts (Linux: a systemd user unit;
127
+ Windows and WSL: a scheduled task at sign-in), stop doing that,
128
+ or say whether it does. Turning it on or off leaves a host that
129
+ runs now as it is
130
+
131
+ The data folder is ~/.centralu (CC_DATA_DIR overrides it). The app on another computer reaches
132
+ this host through an SSH local forward, and starts it with --detach when it is not running.
133
+ Stop a foreground one with Ctrl+C or SIGTERM, a detached one with --stop. A detached one writes
134
+ how its start went to serve.log in the data folder; the host's own log is host.log.
135
+
136
+ serve, --detach and --connection keep ~/.centralu/bin/centralu up to date: a launcher with absolute
137
+ paths, for SSH sessions whose PATH has neither npm's global folder nor node. Not when they run
138
+ from an install the app made over SSH, which has a launcher of its own.`
139
+
140
+ /** The flags that each make serve a different command */
141
+ const MODE_FLAGS = { '--connection': 'connection', '--rotate-token': 'rotate', '--detach': 'detach', '--stop': 'stop' }
142
+ const FLAG_OF = { ...Object.fromEntries(Object.entries(MODE_FLAGS).map(([f, m]) => [m, f])), autostart: '--autostart' }
143
+
144
+ /** What `--autostart` takes */
145
+ export const AUTOSTART_ACTIONS = ['on', 'off', 'status']
146
+
147
+ /**
148
+ * The argument a detached launcher is started with: what it needs from the `--detach` that started
149
+ * it, which a process created through WMI does not inherit (it gets the account's default
150
+ * environment, not this one). Never a secret: the token stays in serve.json. Internal, not in the help.
151
+ */
152
+ const CHILD_FLAG = '--detached-child='
153
+
154
+ /**
155
+ * What the arguments after `serve` ask for: `{ mode, port }` (plus `child` for a detached launcher,
156
+ * `autostart` for `--autostart`) or `{ error }`.
157
+ *
158
+ * `mode` is `serve`, `detach`, `stop`, `connection`, `rotate`, `autostart` or `help`; `port` is null
159
+ * when not given. `--port` goes with `serve` and `--detach` only: `--connection` has to report the
160
+ * port `serve` uses, not one the caller names, `--stop` stops the one that runs, and the entry
161
+ * `--autostart` writes starts serve on the port it recorded.
162
+ */
163
+ export function parseServeArgs(argv) {
164
+ let mode = 'serve'
165
+ let port = null
166
+ let help = false
167
+ let child = null
168
+ let autostart = null
169
+ for (let i = 0; i < argv.length; i++) {
170
+ const a = argv[i]
171
+ if (a === '--autostart' || a.startsWith('--autostart=')) {
172
+ const raw = a === '--autostart' ? argv[++i] : a.slice('--autostart='.length)
173
+ if (!AUTOSTART_ACTIONS.includes(raw)) return { error: `--autostart needs on, off or status (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
174
+ if (mode !== 'serve' && mode !== 'autostart') return { error: `${FLAG_OF[mode]} and --autostart are separate commands; pass one` }
175
+ mode = 'autostart'
176
+ autostart = raw
177
+ } else if (Object.hasOwn(MODE_FLAGS, a)) {
178
+ const m = MODE_FLAGS[a]
179
+ if (mode !== 'serve' && mode !== m) return { error: `${FLAG_OF[mode]} and ${a} are separate commands; pass one` }
180
+ mode = m
181
+ } else if (a === '--help' || a === '-h') help = true
182
+ else if (a.startsWith(CHILD_FLAG)) child = a.slice(CHILD_FLAG.length)
183
+ else if (a === '--port' || a.startsWith('--port=')) {
184
+ const raw = a === '--port' ? argv[++i] : a.slice('--port='.length)
185
+ const n = parsePort(raw)
186
+ if (n === null) return { error: `--port needs a port number between 1 and 65535 (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
187
+ port = n
188
+ } else return { error: `unknown option for serve: ${a}\n\n${SERVE_HELP}` }
189
+ }
190
+ if (help) return { mode: 'help', port }
191
+ if (child !== null) {
192
+ if (mode !== 'serve') return { error: `${CHILD_FLAG.slice(0, -1)} is how --detach starts serve, not an option of ${FLAG_OF[mode]}` }
193
+ return { mode, port, child }
194
+ }
195
+ if ((mode === 'connection' || mode === 'rotate') && port !== null) {
196
+ return { error: '--port goes with `centralu serve`; `--connection` reports the port serve listens on' }
197
+ }
198
+ if (mode === 'stop' && port !== null) return { error: '--stop stops the serve that is running, on the port it recorded; it takes no --port' }
199
+ if (mode === 'autostart') {
200
+ if (port !== null) return { error: '--autostart takes no --port: the entry it writes starts serve on the port serve recorded' }
201
+ return { mode, port, autostart }
202
+ }
203
+ return { mode, port }
204
+ }
205
+
206
+ function parsePort(raw) {
207
+ if (typeof raw !== 'string' || !/^\d{1,5}$/.test(raw)) return null
208
+ const n = Number(raw)
209
+ return n >= 1 && n <= 65535 ? n : null
210
+ }
211
+
212
+ /** The data folder: the host's own rule (`dataRoot()` in agent-host), CC_DATA_DIR or ~/.centralu */
213
+ export function serveDataDir(env, home) {
214
+ return env.CC_DATA_DIR || join(home, '.centralu')
215
+ }
216
+
217
+ /** The state, or null when there is none yet or it cannot be read as one */
218
+ export function readServeState(dataDir) {
219
+ let raw
220
+ try {
221
+ raw = readFileSync(join(dataDir, SERVE_STATE_FILE), 'utf8')
222
+ } catch {
223
+ return null
224
+ }
225
+ try {
226
+ const v = JSON.parse(raw)
227
+ if (typeof v?.token !== 'string' || v.token.length < 32) return null
228
+ return { token: v.token, port: Number.isInteger(v.port) && v.port >= 1 && v.port <= 65535 ? v.port : null }
229
+ } catch {
230
+ return null
231
+ }
232
+ }
233
+
234
+ /**
235
+ * The state, created on first use. Never replaces a token that is already there.
236
+ *
237
+ * Created with `wx`, so two first runs at the same moment (a `serve` and a `--connection`) cannot
238
+ * each write a different token: the second one fails to create and reads the first one's. A file
239
+ * that exists but cannot be read as state is refused rather than overwritten: it may be a token
240
+ * the client already holds, and a silent new one would lock that client out with "bad token".
241
+ */
242
+ export function ensureServeState(dataDir, platform = process.platform) {
243
+ mkdirSync(dataDir, { recursive: true })
244
+ const file = join(dataDir, SERVE_STATE_FILE)
245
+ if (!existsSync(file)) {
246
+ const token = randomBytes(32).toString('base64url')
247
+ try {
248
+ writePrivate(file, { v: 1, token, port: null }, 'wx')
249
+ } catch (e) {
250
+ if (e?.code !== 'EEXIST') throw e
251
+ }
252
+ }
253
+ const state = readServeState(dataDir)
254
+ if (!state) throw new Error(`${file} exists but is not a serve state file. Move it aside and run again.`)
255
+ keepPrivate(file, platform)
256
+ return state
257
+ }
258
+
259
+ /** Writes the port `serve` listened on, keeping the token. Atomic: a temp file renamed over it */
260
+ export function recordServePort(dataDir, port, platform = process.platform) {
261
+ const file = join(dataDir, SERVE_STATE_FILE)
262
+ const state = readServeState(dataDir)
263
+ if (!state) throw new Error(`${file} is missing or unreadable`)
264
+ if (state.port === port) return
265
+ const tmp = `${file}.${process.pid}.tmp`
266
+ writePrivate(tmp, { v: 1, token: state.token, port }, 'wx')
267
+ renameSync(tmp, file)
268
+ keepPrivate(file, platform)
269
+ }
270
+
271
+ /**
272
+ * A new token, keeping the port. Only when the person asks (`--rotate-token`): every client that
273
+ * stored the old one has to fetch the new one with `--connection`, and a serve already running
274
+ * keeps answering to the old one until it restarts.
275
+ */
276
+ export function rotateServeToken(dataDir, platform = process.platform) {
277
+ const before = ensureServeState(dataDir, platform)
278
+ const file = join(dataDir, SERVE_STATE_FILE)
279
+ const tmp = `${file}.${process.pid}.tmp`
280
+ writePrivate(tmp, { v: 1, token: randomBytes(32).toString('base64url'), port: before.port }, 'wx')
281
+ renameSync(tmp, file)
282
+ keepPrivate(file, platform)
283
+ return readServeState(dataDir)
284
+ }
285
+
286
+ /**
287
+ * The launcher `serve` keeps at `<data folder>/bin/centralu` (`centralu.cmd` on Windows).
288
+ *
289
+ * The client runs `ssh -T -o BatchMode=yes <target> centralu …`, and a non-interactive SSH shell
290
+ * reads no profile: npm's global bin under nvm, fnm, volta or `~/.npm-global` is not on its PATH,
291
+ * and often neither is `node`. So the plain name fails with exit 127, and the person, who just ran
292
+ * `centralu serve` in their own shell, sees no reason why. This file names both by absolute path,
293
+ * so `~/.centralu/bin/centralu` works from any shell. It is rewritten whenever it differs from
294
+ * what this run would write, which follows a Node upgrade or a moved npm prefix on the next start.
295
+ */
296
+ export function launcherScript(platform, execPath, cliPath) {
297
+ if (platform === 'win32') {
298
+ return `@echo off\r\nrem Written by \`centralu serve\`: reaches this install without npm's folder on PATH.\r\n"${execPath}" "${cliPath}" %*\r\n`
299
+ }
300
+ const q = (v) => `'${String(v).replaceAll("'", "'\\''")}'`
301
+ return (
302
+ '#!/bin/sh\n' +
303
+ "# Written by `centralu serve`: reaches this install from a shell whose PATH has neither npm's global\n" +
304
+ '# folder nor node (a non-interactive SSH session). Rewritten when node or the package moves.\n' +
305
+ `exec ${q(execPath)} ${q(cliPath)} "$@"\n`
306
+ )
307
+ }
308
+
309
+ /** Writes the launcher if it is missing or out of date. Returns its path. Never fatal to the caller */
310
+ export function ensureLauncher(dataDir, { platform = process.platform, execPath = process.execPath, cliPath }) {
311
+ const file = join(dataDir, 'bin', platform === 'win32' ? 'centralu.cmd' : 'centralu')
312
+ const want = launcherScript(platform, execPath, cliPath)
313
+ let have = null
314
+ try {
315
+ have = readFileSync(file, 'utf8')
316
+ } catch {
317
+ // not there yet
318
+ }
319
+ if (have !== want) {
320
+ mkdirSync(dirname(file), { recursive: true })
321
+ const tmp = `${file}.${process.pid}.tmp`
322
+ const fd = openSync(tmp, 'w', 0o755)
323
+ try {
324
+ writeSync(fd, want)
325
+ } finally {
326
+ closeSync(fd)
327
+ }
328
+ renameSync(tmp, file)
329
+ }
330
+ if (platform !== 'win32' && (statSync(file).mode & 0o777) !== 0o755) chmodSync(file, 0o755)
331
+ return file
332
+ }
333
+
334
+ /**
335
+ * ensureLauncher, with a failure said on stderr instead of thrown: the launcher is a convenience.
336
+ *
337
+ * Not under the managed launcher (`CENTRALU_MANAGED=1`, docs/plans/remote-hub.md §10.1): an install
338
+ * the hub made over ssh has its own launcher in `<data>/remote/bin/`, which reads `current`. Writing
339
+ * `<data>/bin/centralu` from there would point an npm install's launcher at one managed version, and
340
+ * the two would rewrite each other's file on every start.
341
+ */
342
+ function keepLauncher(dataDir, cliPath, env) {
343
+ if (!cliPath || env?.CENTRALU_MANAGED === '1') return
344
+ try {
345
+ ensureLauncher(dataDir, { cliPath })
346
+ } catch (e) {
347
+ say(`could not write the launcher in ${join(dataDir, 'bin')}: ${e.message}`)
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Created 0600 from the first byte, not chmod-ed after: a file written with the default mode and
353
+ * narrowed afterwards is readable by everyone on the machine for the moment in between.
354
+ */
355
+ function writePrivate(file, value, flag) {
356
+ const fd = openSync(file, flag, 0o600)
357
+ try {
358
+ writeSync(fd, `${JSON.stringify(value)}\n`)
359
+ } finally {
360
+ closeSync(fd)
361
+ }
362
+ }
363
+
364
+ /**
365
+ * 0600 again on every use. A copy, a restore from backup or an editor that rewrites the file can
366
+ * leave it readable by the group; the token in it is the key to every RPC. Windows has no mode
367
+ * bits to set (the profile folder's ACL is what protects it there).
368
+ */
369
+ function keepPrivate(file, platform) {
370
+ if (platform === 'win32') return
371
+ if ((statSync(file).mode & 0o777) !== 0o600) chmodSync(file, 0o600)
372
+ }
373
+
374
+ /**
375
+ * The protocol a host folder speaks, from the `bundle-info.json` the bundler writes next to
376
+ * `main.mjs`. For a host run from source (`CENTRALU_HOST_ENTRY=…/src/main.ts`) there is no bundle
377
+ * info, so the constant is read where the bundler reads it. null when neither is there.
378
+ */
379
+ export function protocolVersionOf(entry) {
380
+ try {
381
+ const v = JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).protocolVersion
382
+ if (Number.isInteger(v)) return v
383
+ } catch {
384
+ // not a bundle; try the source below
385
+ }
386
+ try {
387
+ const src = readFileSync(join(dirname(entry), '..', '..', 'protocol', 'src', 'envelope.ts'), 'utf8')
388
+ const m = /export const PROTOCOL_VERSION = (\d+)/.exec(src)
389
+ if (m) return Number(m[1])
390
+ } catch {
391
+ // nothing to read
392
+ }
393
+ return null
394
+ }
395
+
396
+ /**
397
+ * The `--connection` answer. The shape is the client's interface (docs/agent-host.md §4.7).
398
+ * `install` (additive, the line stays `v: 1`) says what runs this command (`installInfo`); `detach`
399
+ * is added by `--detach` only.
400
+ */
401
+ export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, autostart, detach }) {
402
+ return JSON.stringify({
403
+ v: 1,
404
+ port,
405
+ token,
406
+ version,
407
+ protocolVersion,
408
+ dataDir,
409
+ hostRunning,
410
+ ...(install ? { install } : {}),
411
+ ...(autostart ? { autostart } : {}),
412
+ ...(detach ? { detach } : {}),
413
+ })
414
+ }
415
+
416
+ /**
417
+ * The install the hub manages on this machine (docs/plans/remote-hub.md §10.1): side-by-side
418
+ * versions, the pinned Node they run on, and two pointer files naming what runs and what to roll
419
+ * back to. The hub's installer writes it; `serve` only reads it.
420
+ */
421
+ export function remoteLayout(dataDir) {
422
+ const root = join(dataDir, 'remote')
423
+ return {
424
+ root,
425
+ current: join(root, 'current'),
426
+ previous: join(root, 'previous'),
427
+ versions: join(root, 'versions'),
428
+ node: join(root, 'node'),
429
+ bin: join(root, 'bin'),
430
+ }
431
+ }
432
+
433
+ /** A word a pointer file may hold: a folder name, nothing a path could escape with */
434
+ const POINTER_WORD = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/
435
+
436
+ /** `current` / `previous`: one line, "<centralu version> <node version>". null for anything else */
437
+ export function parsePointer(text) {
438
+ const m = /^\s*(\S+)[ \t]+(\S+)\s*$/.exec(String(text ?? ''))
439
+ if (!m || !POINTER_WORD.test(m[1]) || !POINTER_WORD.test(m[2])) return null
440
+ return { version: m[1], node: m[2] }
441
+ }
442
+
443
+ /**
444
+ * The connection line's `install` field (docs/plans/remote-hub.md §10.5): whether this command was
445
+ * started by the managed launcher, the managed install's `current` and `previous`, and the Node
446
+ * running this command. Read from the machine at every ask, never kept by the hub (S12).
447
+ */
448
+ export function installInfo(dataDir, env, nodeVersion = process.versions.node) {
449
+ const l = remoteLayout(dataDir)
450
+ const read = (file) => {
451
+ try {
452
+ return parsePointer(readFileSync(file, 'utf8'))
453
+ } catch {
454
+ return null
455
+ }
456
+ }
457
+ return { managed: env.CENTRALU_MANAGED === '1', current: read(l.current), previous: read(l.previous), node: nodeVersion }
458
+ }
459
+
460
+ /**
461
+ * The managed launcher, `<data>/remote/bin/centralu` (`centralu.cmd` on Windows), as the installer
462
+ * writes it (docs/plans/remote-hub.md §10.1, S8).
463
+ *
464
+ * **Its text never changes.** It reads `current` at every start and runs that version's
465
+ * `centralu.mjs` on that version's Node, so switching versions is one file replaced by a rename,
466
+ * atomic on both systems. A launcher rewritten per version would be replaced while it may be
467
+ * running, and cmd.exe reads a batch file by offset as it runs. It sets `CENTRALU_MANAGED=1`, so
468
+ * the `serve` it starts leaves `<data>/bin/` to an npm install on the same machine.
469
+ *
470
+ * Both refuse a pointer that holds anything but plain version words, so an edited `current` cannot
471
+ * point it outside the install.
472
+ */
473
+ export function managedLauncherScript(platform) {
474
+ if (platform === 'win32') {
475
+ return (
476
+ [
477
+ '@echo off',
478
+ 'rem The Centralu launcher for the install the app manages over SSH. It never changes: it reads',
479
+ 'rem "current" at every start, so switching versions replaces that file and never this one.',
480
+ 'setlocal',
481
+ 'set "CENTRALU_REMOTE=%~dp0.."',
482
+ 'set "CENTRALU_LINE="',
483
+ 'set "CENTRALU_V="',
484
+ 'set "CENTRALU_N="',
485
+ 'set /p CENTRALU_LINE=<"%CENTRALU_REMOTE%\\current"',
486
+ 'for /f "tokens=1,2" %%a in ("%CENTRALU_LINE%") do (set "CENTRALU_V=%%a" & set "CENTRALU_N=%%b")',
487
+ 'if not defined CENTRALU_N goto none',
488
+ 'echo %CENTRALU_V% %CENTRALU_N%| findstr /r /c:"^[0-9A-Za-z][0-9A-Za-z.+-]* [0-9A-Za-z][0-9A-Za-z.+-]*$" >nul || goto none',
489
+ 'set CENTRALU_MANAGED=1',
490
+ '"%CENTRALU_REMOTE%\\node\\v%CENTRALU_N%\\node.exe" "%CENTRALU_REMOTE%\\versions\\%CENTRALU_V%\\node_modules\\centralu\\bin\\centralu.mjs" %*',
491
+ 'exit /b %ERRORLEVEL%',
492
+ ':none',
493
+ 'echo Centralu: no version is installed in %CENTRALU_REMOTE% 1>&2',
494
+ 'exit /b 1',
495
+ ].join('\r\n') + '\r\n'
496
+ )
497
+ }
498
+ return [
499
+ '#!/bin/sh',
500
+ '# The Centralu launcher for the install the app manages over SSH. It never changes: it reads',
501
+ '# `current` at every start, so switching versions replaces that file and never this one.',
502
+ 'remote=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) || exit 1',
503
+ 'v= n=',
504
+ 'read -r v n < "$remote/current" 2>/dev/null',
505
+ 'case "$v" in ""|[!0-9A-Za-z]*|*[!0-9A-Za-z.+-]*) echo "Centralu: no version is installed in $remote" >&2; exit 1 ;; esac',
506
+ 'case "$n" in ""|[!0-9A-Za-z]*|*[!0-9A-Za-z.+-]*) echo "Centralu: no version is installed in $remote" >&2; exit 1 ;; esac',
507
+ 'CENTRALU_MANAGED=1',
508
+ 'export CENTRALU_MANAGED',
509
+ 'exec "$remote/node/v$n/bin/node" "$remote/versions/$v/node_modules/centralu/bin/centralu.mjs" "$@"',
510
+ '',
511
+ ].join('\n')
512
+ }
513
+
514
+ /**
515
+ * The host's environment.
516
+ *
517
+ * - The token travels in `CC_HOST_TOKEN`, not `--token`: arguments are readable by every user on
518
+ * the machine (`ps`), a process's environment only by its owner. The host deletes it after
519
+ * reading, so nothing it spawns inherits it.
520
+ * - `CC_HOST_SOURCE` is the record `hello_ok.build` is made from (keeper-link.ts); without it a
521
+ * client could not see which version the host it reached is.
522
+ * - Headless: no `DISPLAY` / `WAYLAND_DISPLAY`. Nothing the host or an agent starts gets a screen
523
+ * to open a window or a keyring prompt on, so a tool that would ask falls back to its file store
524
+ * or fails with a message in the log instead of waiting on a dialog nobody can see.
525
+ * - The keeper's and dev mode's variables are dropped: this host is neither. So is
526
+ * `CENTRALU_MANAGED`, which is the launcher's business and would otherwise reach every agent.
527
+ * - `CC_SERVE=1` lets the host take `host.stop` (`--stop`); a host the app runs refuses it.
528
+ */
529
+ export function hostEnv(env, { token, dataDir, version, hostDir }) {
530
+ const out = { ...env }
531
+ for (const k of ['DISPLAY', 'WAYLAND_DISPLAY', 'CC_KEEPER', 'CC_FRONT_DOOR', 'CC_DEV', 'CC_HOST_TOKEN', 'CC_HOST_SOURCE', 'CENTRALU_MANAGED']) delete out[k]
532
+ out.CC_DATA_DIR = dataDir
533
+ // The host `host.stop` may end (`centralu serve --stop`): nothing else stops it in order on Windows
534
+ out.CC_SERVE = '1'
535
+ out.CC_HOST_TOKEN = token
536
+ out.CC_HOST_SOURCE = JSON.stringify({ version, bundlePath: hostDir })
537
+ return out
538
+ }
539
+
540
+ /**
541
+ * How to start the host.
542
+ *
543
+ * `--watch-parent` with stdin a pipe: if this launcher dies however it dies (SIGKILL included), the
544
+ * pipe closes and the host shuts down with its children, instead of living on unsupervised.
545
+ * `detached` on POSIX puts the host in its own process group, so the terminal's Ctrl+C reaches
546
+ * only this launcher, which passes one SIGINT to the host alone; the host then stops its own
547
+ * children in order. Without it the terminal would signal every agent at the same time as the
548
+ * host. Not on Windows: there `detached` means a new console, and Ctrl+C reaches the host through
549
+ * the console it shares with this launcher.
550
+ */
551
+ export function hostCommand(execPath, entry, { port, dataDir, platform }) {
552
+ return {
553
+ command: execPath,
554
+ args: [
555
+ // A source entry (development, tests) needs the TypeScript loader; a bundle does not.
556
+ ...(entry.endsWith('.ts') ? ['--import', 'tsx'] : []),
557
+ entry,
558
+ '--port',
559
+ String(port),
560
+ '--db',
561
+ join(dataDir, 'store.db'),
562
+ '--watch-parent',
563
+ ],
564
+ detached: platform !== 'win32',
565
+ }
566
+ }
567
+
568
+ /** The port from the host's ready line (`{"ready":true,"port":…,"token":…}`), null for any other line */
569
+ export function readyPort(line) {
570
+ try {
571
+ const v = JSON.parse(line)
572
+ return v?.ready === true && Number.isInteger(v.port) ? v.port : null
573
+ } catch {
574
+ return null
575
+ }
576
+ }
577
+
578
+ /**
579
+ * The protocol a host's `version_mismatch` refusal names, from either wording: this one's
580
+ * ("this host speaks protocol 2") or the one before it ("server 2, client 1"). null if neither.
581
+ */
582
+ export function mismatchServerVersion(message) {
583
+ const m = /host speaks protocol (\d+)|\bserver (\d+)/.exec(String(message ?? ''))
584
+ return m ? Number(m[1] ?? m[2]) : null
585
+ }
586
+
587
+ /**
588
+ * Whether a host that holds this token answers on the port: a real hello, not a TCP connect, so a
589
+ * stranger's program on the same port does not count, and the answer carries the running host's
590
+ * own protocol and version (which differ from the installed package's after an `npm i -g` the
591
+ * host was not restarted for).
592
+ *
593
+ * `{ running, protocolVersion?, version? }`. Node 22.4+ has a global WebSocket; on an older 22
594
+ * this falls back to a TCP connect and reports only `running`.
595
+ */
596
+ export function probeHost({ port, token, protocolVersion, timeoutMs = 2000, WebSocketImpl = globalThis.WebSocket }) {
597
+ if (typeof WebSocketImpl !== 'function') return tcpProbe(port, timeoutMs)
598
+ return new Promise((resolve) => {
599
+ let ws = null
600
+ let settled = false
601
+ const done = (result) => {
602
+ if (settled) return
603
+ settled = true
604
+ clearTimeout(timer)
605
+ try {
606
+ ws?.close()
607
+ } catch {
608
+ // already closed
609
+ }
610
+ resolve(result)
611
+ }
612
+ const timer = setTimeout(() => done({ running: false }), timeoutMs)
613
+ try {
614
+ ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
615
+ } catch {
616
+ done({ running: false })
617
+ return
618
+ }
619
+ ws.onopen = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
620
+ ws.onmessage = (ev) => {
621
+ let m
622
+ try {
623
+ m = JSON.parse(String(ev.data))
624
+ } catch {
625
+ return
626
+ }
627
+ if (m?.kind === 'hello_ok') {
628
+ done({
629
+ running: true,
630
+ protocolVersion: Number.isInteger(m.protocolVersion) ? m.protocolVersion : undefined,
631
+ version: typeof m.build?.version === 'string' ? m.build.version : undefined,
632
+ })
633
+ } else if (m?.kind === 'res' && m.ok === false && m.error?.code === 'version_mismatch') {
634
+ // The token was right (it is checked first), so this is our host, on another protocol
635
+ done({ running: true, protocolVersion: mismatchServerVersion(m.error.message) ?? undefined })
636
+ }
637
+ }
638
+ ws.onerror = () => done({ running: false })
639
+ ws.onclose = () => done({ running: false })
640
+ })
641
+ }
642
+
643
+ function tcpProbe(port, timeoutMs) {
644
+ return new Promise((resolve) => {
645
+ const s = connect({ host: '127.0.0.1', port })
646
+ const done = (running) => {
647
+ s.destroy()
648
+ resolve({ running })
649
+ }
650
+ s.setTimeout(timeoutMs, () => done(false))
651
+ s.once('connect', () => done(true))
652
+ s.once('error', () => done(false))
653
+ })
654
+ }
655
+
656
+ /** Who `host.lock` names as the data folder's owner, or null */
657
+ export function lockHolderPid(dataDir) {
658
+ try {
659
+ const text = readFileSync(join(dataDir, 'host.lock'), 'utf8').trim()
660
+ const pid = /^\d+$/.test(text) ? Number(text) : JSON.parse(text)?.pid
661
+ return Number.isInteger(pid) && pid > 0 ? pid : null
662
+ } catch {
663
+ return null
664
+ }
665
+ }
666
+
667
+ function isAlive(pid) {
668
+ try {
669
+ process.kill(pid, 0)
670
+ return true
671
+ } catch (e) {
672
+ return e?.code === 'EPERM'
673
+ }
674
+ }
675
+
676
+ /**
677
+ * Where `say` writes: stderr, except in a detached launcher, which nobody reads the stderr of and
678
+ * which writes serve.log instead (`runDetachedChild`).
679
+ */
680
+ let sink = (text) => process.stderr.write(text)
681
+
682
+ /** Writes a line to stderr, prefixed so it reads apart from the host's own log lines */
683
+ function say(line) {
684
+ sink(`[centralu serve] ${line}\n`)
685
+ }
686
+
687
+ /** What `--connection` answers, as fields: the running host's versions when one answers, else the installed ones */
688
+ async function describe({ env, dataDir, state, entry, version }) {
689
+ const port = state.port ?? DEFAULT_SERVE_PORT
690
+ const installed = protocolVersionOf(entry)
691
+ const probe = await probeHost({ port, token: state.token, protocolVersion: installed })
692
+ const protocolVersion = (probe.running ? probe.protocolVersion : undefined) ?? installed
693
+ if (protocolVersion === null) return null
694
+ return {
695
+ port,
696
+ token: state.token,
697
+ version: (probe.running ? probe.version : undefined) ?? version,
698
+ protocolVersion,
699
+ dataDir,
700
+ hostRunning: probe.running,
701
+ install: installInfo(dataDir, env),
702
+ autostart: autostartInfo(dataDir, process.platform, env),
703
+ }
704
+ }
705
+
706
+ /**
707
+ * `centralu serve --connection`. stdout carries exactly one line, the JSON; everything else goes to
708
+ * stderr. Exit 1 when there is no host to describe.
709
+ */
710
+ export async function printConnection({ env, home, entry, version, cliPath }) {
711
+ const dataDir = serveDataDir(env, home)
712
+ const state = ensureServeState(dataDir)
713
+ keepLauncher(dataDir, cliPath, env)
714
+ const d = await describe({ env, dataDir, state, entry, version })
715
+ if (!d) {
716
+ say(`cannot read the protocol version of the host at ${entry}`)
717
+ return 1
718
+ }
719
+ process.stdout.write(`${connectionLine(d)}\n`)
720
+ return 0
721
+ }
722
+
723
+ /** serve.pid: `{ pid, hostPid, port }` of the launcher that runs, or null */
724
+ export function readServePid(dataDir) {
725
+ try {
726
+ const v = JSON.parse(readFileSync(join(dataDir, SERVE_PID_FILE), 'utf8'))
727
+ return Number.isInteger(v?.pid) && v.pid > 0 ? { pid: v.pid, hostPid: Number.isInteger(v.hostPid) ? v.hostPid : null, port: v.port ?? null } : null
728
+ } catch {
729
+ return null
730
+ }
731
+ }
732
+
733
+ function removeServePid(dataDir, pid) {
734
+ if (readServePid(dataDir)?.pid !== pid) return
735
+ try {
736
+ rmSync(join(dataDir, SERVE_PID_FILE), { force: true })
737
+ } catch {
738
+ // another serve's by now, or gone
739
+ }
740
+ }
741
+
742
+ /**
743
+ * `centralu serve`. Resolves with the exit code once the host has exited.
744
+ *
745
+ * The host's stdout is read here and never passed on: its ready line carries the token, which is
746
+ * printed nowhere but `--connection`. Every other line the host writes to stdout (a lock conflict,
747
+ * a store from a newer build) it also writes to stderr, which is passed through untouched.
748
+ *
749
+ * `logFd` is serve.log of a detached launcher (`runDetachedChild`): the host's stderr goes there
750
+ * until it is ready, so a host that dies while starting (a native module that does not load, a store
751
+ * from a newer build) says why in a file `--detach` can quote. From then on host.log has every line,
752
+ * and serve.log stops growing.
753
+ */
754
+ export async function runServe({ env, home, entry, version, cliPath, port: askedPort, platform = process.platform, execPath = process.execPath, logFd = null }) {
755
+ const dataDir = serveDataDir(env, home)
756
+ const state = ensureServeState(dataDir, platform)
757
+ keepLauncher(dataDir, cliPath, env)
758
+ const protocolVersion = protocolVersionOf(entry)
759
+
760
+ // A serve that is already up answers on the port it recorded; say so before starting a host that
761
+ // would only fail on the data folder's lock with a less specific message.
762
+ const recorded = state.port ?? DEFAULT_SERVE_PORT
763
+ const existing = await probeHost({ port: recorded, token: state.token, protocolVersion })
764
+ if (existing.running) {
765
+ const pid = lockHolderPid(dataDir)
766
+ say(`centralu serve is already running for ${dataDir} on 127.0.0.1:${recorded}${pid ? ` (pid ${pid})` : ''}. Not starting a second one.`)
767
+ return 1
768
+ }
769
+
770
+ const onWindows = await windowsListening(env, platform)
771
+ const chosen = await chooseServePort({ asked: askedPort, recorded: state.port, taken: async (p) => onWindows.has(p) || (await listensHere(p)) })
772
+ if (chosen === null) {
773
+ say(`another program listens on 127.0.0.1:${recorded} and on the ${SERVE_PORT_TRIES - 1} ports above it. Give a free one with --port <n>.`)
774
+ return 1
775
+ }
776
+ const port = chosen.port
777
+ if (chosen.from !== null) {
778
+ const who = onWindows.has(chosen.from) ? 'Windows' : 'another program'
779
+ say(`127.0.0.1:${chosen.from} is in use by ${who}; starting on ${port} instead (kept for the next start)`)
780
+ }
781
+
782
+ const plan = hostCommand(execPath, entry, { port, dataDir, platform })
783
+ const child = spawn(plan.command, plan.args, {
784
+ stdio: ['pipe', 'pipe', logFd === null ? 'inherit' : 'pipe'],
785
+ env: hostEnv(env, { token: state.token, dataDir, version, hostDir: dirname(entry) }),
786
+ detached: plan.detached,
787
+ windowsHide: true,
788
+ })
789
+
790
+ let ready = false
791
+ if (logFd !== null) {
792
+ // Read to the end in any case: a pipe nobody drains stops the host once its buffer is full
793
+ child.stderr.on('data', (b) => {
794
+ if (ready) return
795
+ try {
796
+ writeSync(logFd, b)
797
+ } catch {
798
+ // the log is a convenience
799
+ }
800
+ })
801
+ }
802
+ if (child.pid) {
803
+ try {
804
+ writeFileSync(join(dataDir, SERVE_PID_FILE), `${JSON.stringify({ pid: process.pid, hostPid: child.pid, port })}\n`)
805
+ } catch (e) {
806
+ say(`could not write ${SERVE_PID_FILE}: ${e.message}`)
807
+ }
808
+ }
809
+ createInterface({ input: child.stdout }).on('line', (line) => {
810
+ const p = readyPort(line)
811
+ if (p === null || ready) return
812
+ ready = true
813
+ try {
814
+ recordServePort(dataDir, p, platform)
815
+ } catch (e) {
816
+ say(`could not record the port in ${join(dataDir, SERVE_STATE_FILE)}: ${e.message}`)
817
+ }
818
+ say(`listening on 127.0.0.1:${p} (Centralu ${version}, protocol ${protocolVersion ?? 'unknown'}, data ${dataDir})`)
819
+ if (logFd === null) say(`from your computer: ssh -N -L ${p}:127.0.0.1:${p} <this machine>, or let the app connect over SSH`)
820
+ else say(`the host's own log continues in ${join(dataDir, 'host.log')}`)
821
+ })
822
+
823
+ /*
824
+ * One signal to the host alone, whatever reached this launcher. SIGHUP (the terminal closed) is
825
+ * passed as SIGTERM: the host has no SIGHUP handler, and Node's default for it is to exit on the
826
+ * spot, skipping the shutdown that stops the agents. A second signal changes nothing on the host
827
+ * side (its shutdown runs once), so forwarding every one is safe.
828
+ */
829
+ const forward = (sig) => {
830
+ if (child.exitCode !== null || child.signalCode !== null) return
831
+ if (platform === 'win32') return // the host shares the console and received Ctrl+C itself
832
+ try {
833
+ child.kill(sig === 'SIGHUP' ? 'SIGTERM' : sig)
834
+ } catch {
835
+ // already gone
836
+ }
837
+ }
838
+ const signals = platform === 'win32' ? ['SIGINT'] : ['SIGINT', 'SIGTERM', 'SIGHUP']
839
+ for (const s of signals) process.on(s, forward)
840
+
841
+ return await new Promise((resolve) => {
842
+ child.on('error', (e) => {
843
+ removeServePid(dataDir, process.pid)
844
+ say(`could not start the host (${plan.command}): ${e.message}`)
845
+ resolve(1)
846
+ })
847
+ child.on('exit', (code, signal) => {
848
+ for (const s of signals) process.off(s, forward)
849
+ removeServePid(dataDir, process.pid)
850
+ if (!ready && code !== 0) {
851
+ const pid = lockHolderPid(dataDir)
852
+ if (pid && pid !== child.pid && isAlive(pid)) {
853
+ say(
854
+ `the host did not start. ${dataDir} belongs to another Centralu host (pid ${pid}), ` +
855
+ 'probably the Centralu app on this machine. Quit it, or point CC_DATA_DIR at another folder.',
856
+ )
857
+ }
858
+ }
859
+ if (logFd !== null) say(`the host exited (${signal ?? code})`)
860
+ resolve(code ?? (signal ? 1 : 0))
861
+ })
862
+ })
863
+ }
864
+
865
+ /** `centralu serve --rotate-token` */
866
+ export function rotateToken({ env, home }) {
867
+ const dataDir = serveDataDir(env, home)
868
+ rotateServeToken(dataDir)
869
+ say(`replaced the token in ${join(dataDir, SERVE_STATE_FILE)}.`)
870
+ say('A serve that is running keeps the old one until it restarts; clients read the new one with --connection.')
871
+ return 0
872
+ }
873
+
874
+ // ── --detach ────────────────────────────────────────────────────────────────────────────────
875
+
876
+ /**
877
+ * What a detached launcher needs from the `--detach` that started it. A process created through WMI
878
+ * does not inherit this environment: it gets the account's default one. Everything else it needs
879
+ * (the token, the port) is in the data folder. Never a secret.
880
+ */
881
+ export const CARRIED_ENV = ['CC_DATA_DIR', 'CENTRALU_HOST_ENTRY', 'CENTRALU_MANAGED']
882
+
883
+ export function encodeChildSpec({ env, log }) {
884
+ const carried = {}
885
+ for (const k of CARRIED_ENV) if (typeof env[k] === 'string' && env[k]) carried[k] = env[k]
886
+ return Buffer.from(JSON.stringify({ log, env: carried }), 'utf8').toString('base64url')
887
+ }
888
+
889
+ /** `{ log, env }` from `--detached-child=`, only the carried variables; null when it is not one */
890
+ export function decodeChildSpec(raw) {
891
+ try {
892
+ const v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'))
893
+ if (typeof v?.log !== 'string' || !v.log) return null
894
+ const env = {}
895
+ for (const k of CARRIED_ENV) if (typeof v.env?.[k] === 'string') env[k] = v.env[k]
896
+ return { log: v.log, env }
897
+ } catch {
898
+ return null
899
+ }
900
+ }
901
+
902
+ /**
903
+ * How `--detach` starts the launcher so that it outlives the session that asked (measured,
904
+ * docs/plans/remote-hub.md §10.4):
905
+ *
906
+ * setsid posix: a new session, no terminal, nothing from the ssh session's process group. Once
907
+ * `--detach` returns, the launcher is reparented to init (the second fork of the usual
908
+ * double fork); it opens no terminal, so it can never get a controlling one back
909
+ * wmi Windows: `Win32_Process.Create`. Windows' OpenSSH ends every process of a session with
910
+ * it, `Start-Process` included; a process WMI creates is not part of the session
911
+ * wsl inside WSL: the same WMI call from the Windows side, creating `wsl.exe -d <distro>
912
+ * --exec <launcher>`. A `setsid` launcher inside the distro survives the session but not the
913
+ * distro, which WSL stops about 15 s after its last `wsl.exe` client exits; this
914
+ * `wsl.exe` is a client for as long as the host runs, and ending it ends the host
915
+ */
916
+ export function detachHow(platform, env, interop = wslInterop) {
917
+ if (platform === 'win32') return 'wmi'
918
+ if (platform === 'linux' && env.WSL_DISTRO_NAME && interop()) return 'wsl'
919
+ return 'setsid'
920
+ }
921
+
922
+ function wslInterop() {
923
+ return existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') || existsSync('/proc/sys/fs/binfmt_misc/WSLInterop-late')
924
+ }
925
+
926
+ /**
927
+ * One argument for a Windows command line, as `CommandLineToArgvW` and the C runtime read it: always
928
+ * quoted, backslashes before the closing quote doubled. A double quote or a line break cannot be a
929
+ * path on Windows and is refused rather than escaped.
930
+ */
931
+ export function windowsArg(arg) {
932
+ const s = String(arg)
933
+ if (/["\r\n\0]/.test(s)) throw new Error(`cannot pass ${JSON.stringify(s)} on a Windows command line`)
934
+ return `"${s.replace(/(\\+)$/, '$1$1')}"`
935
+ }
936
+
937
+ /** The PowerShell that asks WMI to create `commandLine`. Values cross as base64, so no quoting can break */
938
+ export function wmiScript({ commandLine, cwd = null }) {
939
+ const text = (s) => `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(s, 'utf8').toString('base64')}'))`
940
+ return [
941
+ "$ErrorActionPreference = 'Stop'",
942
+ '[Console]::OutputEncoding = [Text.Encoding]::UTF8',
943
+ `$a = @{ CommandLine = ${text(commandLine)} }`,
944
+ ...(cwd ? [`$a.CurrentDirectory = ${text(cwd)}`] : []),
945
+ // No console window when this runs from a desktop session; left out where it cannot be built
946
+ 'try { $a.ProcessStartupInformation = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 } } catch { }',
947
+ 'try {',
948
+ ' $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $a',
949
+ " if ($r.ReturnValue -eq 21 -and $a.ContainsKey('ProcessStartupInformation')) { $a.Remove('ProcessStartupInformation'); $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $a }",
950
+ ' "CENTRALU-WMI $($r.ReturnValue) $($r.ProcessId)"',
951
+ '} catch { "CENTRALU-WMI-ERROR $($_.Exception.Message)" }',
952
+ ].join('\n')
953
+ }
954
+
955
+ /** `Win32_Process.Create`'s return values (Microsoft's documentation of the method) */
956
+ const WMI_CODES = { 2: 'access denied', 3: 'insufficient privilege', 8: 'unknown failure', 9: 'path not found', 21: 'invalid parameter' }
957
+
958
+ /** What the WMI script said: `{ ok: true, pid }` or `{ ok: false, message }` */
959
+ export function parseWmiAnswer(stdout) {
960
+ const text = String(stdout ?? '')
961
+ const m = /CENTRALU-WMI (\d+) (\d*)/.exec(text)
962
+ if (m) {
963
+ const rv = Number(m[1])
964
+ if (rv === 0 && Number(m[2]) > 0) return { ok: true, pid: Number(m[2]) }
965
+ return { ok: false, message: `WMI's Win32_Process.Create returned ${rv}${WMI_CODES[rv] ? ` (${WMI_CODES[rv]})` : ''}` }
966
+ }
967
+ const e = /CENTRALU-WMI-ERROR (.*)/.exec(text)
968
+ if (e) return { ok: false, message: `WMI refused to create the process: ${e[1].trim()}` }
969
+ return { ok: false, message: 'PowerShell did not answer the WMI request' }
970
+ }
971
+
972
+ /**
973
+ * The sentence for a start WMI did not make. Some managed machines block process creation through
974
+ * WMI (Microsoft Defender's attack surface reduction rule "Block process creations originating from
975
+ * PSExec and WMI commands", off by default); the hub then runs the host bound to its link
976
+ * (docs/plans/remote-hub.md §10.9, decision 3).
977
+ */
978
+ export function wmiBlockedMessage(detail) {
979
+ return (
980
+ `Windows did not start Centralu through WMI (${detail}). Some managed Windows machines block it ` +
981
+ '(Microsoft Defender\'s attack surface reduction rule "Block process creations originating from PSExec and WMI commands"). ' +
982
+ 'Centralu can run there while this computer is linked instead, and stops when the link does.'
983
+ )
984
+ }
985
+
986
+ /** Windows PowerShell 5.1, by full path: under %SystemRoot% on Windows, through the drive WSL mounts inside a distro */
987
+ export function windowsPowershell(how, env, mounts) {
988
+ return windowsTool(how, env, ['System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'], mounts)
989
+ }
990
+
991
+ /** A program under the Windows folder, by full path, from Windows or from inside a WSL distro; null when unreachable */
992
+ function windowsTool(how, env, rel, mounts = () => readFileSync('/proc/mounts', 'utf8')) {
993
+ if (how === 'wmi') return [env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', ...rel].join('\\')
994
+ let text = ''
995
+ try {
996
+ text = mounts()
997
+ } catch {
998
+ // no /proc: not WSL after all
999
+ }
1000
+ for (const line of text.split('\n')) {
1001
+ const [, at, type] = line.split(' ')
1002
+ if (!at || !(type === '9p' || type === 'drvfs')) continue
1003
+ const candidate = [at, 'Windows', ...rel].join('/')
1004
+ if (existsSync(candidate)) return candidate
1005
+ }
1006
+ return null
1007
+ }
1008
+
1009
+ function runCaptured(command, args, env) {
1010
+ return new Promise((resolve) => {
1011
+ let stdout = ''
1012
+ let stderr = ''
1013
+ let child
1014
+ try {
1015
+ child = spawn(command, args, { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
1016
+ } catch (e) {
1017
+ return resolve({ code: null, stdout, stderr: e.message })
1018
+ }
1019
+ child.stdout.on('data', (b) => (stdout += String(b)))
1020
+ child.stderr.on('data', (b) => (stderr += String(b)))
1021
+ child.once('error', (e) => resolve({ code: null, stdout, stderr: e.message }))
1022
+ child.once('close', (code) => resolve({ code, stdout, stderr }))
1023
+ })
1024
+ }
1025
+
1026
+ /**
1027
+ * Starts the launcher outside this session (`detachHow`). `{ ok: true, pid, exited?, alive? }` once
1028
+ * something was created, `{ ok: false, blocked, message }` when nothing was: `blocked` is the WMI
1029
+ * refusal the hub falls back from.
1030
+ */
1031
+ export async function startDetached({ how, execPath, args, env, cwd }) {
1032
+ if (how === 'setsid') {
1033
+ return await new Promise((resolve) => {
1034
+ let child
1035
+ try {
1036
+ child = spawn(execPath, args, { detached: true, stdio: 'ignore', cwd, env, windowsHide: true })
1037
+ } catch (e) {
1038
+ return resolve({ ok: false, blocked: false, message: `could not start ${execPath}: ${e.message}` })
1039
+ }
1040
+ child.once('error', (e) => resolve({ ok: false, blocked: false, message: `could not start ${execPath}: ${e.message}` }))
1041
+ child.once('spawn', () => {
1042
+ const exited = new Promise((r) => child.once('exit', (code, signal) => r(signal ?? code)))
1043
+ child.unref()
1044
+ resolve({ ok: true, pid: child.pid, exited })
1045
+ })
1046
+ })
1047
+ }
1048
+ const ps = windowsPowershell(how, env)
1049
+ if (!ps) return { ok: false, blocked: true, message: wmiBlockedMessage('Windows PowerShell is not reachable from this WSL distro') }
1050
+ let commandLine
1051
+ try {
1052
+ if (how === 'wmi') commandLine = [execPath, ...args].map(windowsArg).join(' ')
1053
+ else {
1054
+ const distro = env.WSL_DISTRO_NAME ?? ''
1055
+ const user = userInfo().username
1056
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(distro)) throw new Error(`not a WSL distro name: ${distro}`)
1057
+ if (!/^[A-Za-z_][A-Za-z0-9_.-]{0,31}$/.test(user)) throw new Error(`cannot name the user ${user} to wsl.exe`)
1058
+ commandLine = ['wsl.exe', '-d', distro, '-u', user, '--cd', cwd, '--exec', execPath, ...args].map(windowsArg).join(' ')
1059
+ }
1060
+ } catch (e) {
1061
+ return { ok: false, blocked: false, message: e.message }
1062
+ }
1063
+ const script = wmiScript({ commandLine, cwd: how === 'wmi' ? cwd : null })
1064
+ const r = await runCaptured(ps, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], env)
1065
+ const answer = parseWmiAnswer(r.stdout)
1066
+ if (!answer.ok) return { ok: false, blocked: true, message: wmiBlockedMessage(r.stdout.trim() || !r.stderr.trim() ? answer.message : r.stderr.trim().split(/\r?\n/).at(-1)) }
1067
+ // A Windows pid can be watched from Windows; from inside WSL it names nothing here
1068
+ /*
1069
+ * Measured on a Windows 11 laptop (2026-10-08): a `wsl.exe` that WMI creates can hang without
1070
+ * running anything in the distro (it starts a second wsl.exe and waits), while `wsl.exe --list`
1071
+ * from the same context answers. `--detach` reports that as a start that never ran, and ends what
1072
+ * it created rather than leave it waiting: this pid and its children, nothing else.
1073
+ */
1074
+ const taskkill = windowsTool(how, env, ['System32', 'taskkill.exe'])
1075
+ const stop = taskkill ? () => runCaptured(taskkill, ['/PID', String(answer.pid), '/T', '/F'], env) : null
1076
+ return { ok: true, pid: answer.pid, alive: how === 'wmi' ? () => isAlive(answer.pid) : null, stop }
1077
+ }
1078
+
1079
+ /** The last lines of a file, for a reason; empty when it cannot be read */
1080
+ function tail(file, n = 15) {
1081
+ try {
1082
+ return readFileSync(file, 'utf8').trimEnd().split(/\r?\n/).slice(-n).join('\n')
1083
+ } catch {
1084
+ return ''
1085
+ }
1086
+ }
1087
+
1088
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
1089
+
1090
+ /**
1091
+ * `centralu serve --detach` (docs/plans/remote-hub.md §10.4, S9): starts `serve` outside this
1092
+ * session (`detachHow`) and returns once its host answers a hello, so the caller, typically the
1093
+ * hub over ssh, can connect at once.
1094
+ *
1095
+ * stdout carries one JSON line, read by the hub (`links/tunnel.ts`): the connection line with
1096
+ * `hostRunning: true` and `detach: { ok: true, how, already }`, or `{ v: 1, detach: { ok: false,
1097
+ * how, reason, message } }`. `reason` is `wmi_blocked` when WMI created nothing or created something
1098
+ * that never ran, which the hub answers with a link-bound start; `exited` and `timeout` quote the
1099
+ * end of serve.log. Exit codes do not survive Windows' ssh (agent-host.md §4.8), so the line is
1100
+ * what counts; the exit code is 0 or 1 for a person at a prompt.
1101
+ *
1102
+ * `start` and the two waits are parameters so tests can stand in for WMI.
1103
+ */
1104
+ export async function runDetach({
1105
+ env,
1106
+ home,
1107
+ entry,
1108
+ version,
1109
+ cliPath,
1110
+ port = null,
1111
+ platform = process.platform,
1112
+ execPath = process.execPath,
1113
+ cwd = process.cwd(),
1114
+ start = startDetached,
1115
+ how = detachHow(platform, env),
1116
+ timeoutMs = 30_000,
1117
+ noStartMs = 10_000,
1118
+ write = (line) => process.stdout.write(line),
1119
+ }) {
1120
+ const dataDir = serveDataDir(env, home)
1121
+ // Created here, so the token exists before the launcher reads it and `answered` reads it again
1122
+ ensureServeState(dataDir, platform)
1123
+ keepLauncher(dataDir, cliPath, env)
1124
+ const fail = (reason, message) => {
1125
+ say(message)
1126
+ write(`${JSON.stringify({ v: 1, detach: { ok: false, how, reason, message } })}\n`)
1127
+ return 1
1128
+ }
1129
+ const answered = async (already) => {
1130
+ const d = await describe({ env, dataDir, state: ensureServeState(dataDir, platform), entry, version })
1131
+ if (!d?.hostRunning) return false
1132
+ write(`${connectionLine({ ...d, detach: { ok: true, how, already } })}\n`)
1133
+ return true
1134
+ }
1135
+ if (await answered(true)) return 0
1136
+
1137
+ const log = join(dataDir, SERVE_LOG_FILE)
1138
+ // One start per log: the last one's is kept beside it, and its absence is how a start that never ran shows
1139
+ try {
1140
+ renameSync(log, `${log}.1`)
1141
+ } catch {
1142
+ // there was none
1143
+ }
1144
+ const args = [cliPath, 'serve', ...(port !== null ? ['--port', String(port)] : []), `${CHILD_FLAG}${encodeChildSpec({ env, log })}`]
1145
+ let started
1146
+ try {
1147
+ started = await start({ how, execPath, args, env, cwd })
1148
+ } catch (e) {
1149
+ return fail('failed', `could not start centralu serve in the background: ${e.message}`)
1150
+ }
1151
+ if (!started.ok) return fail(started.blocked ? 'wmi_blocked' : 'failed', started.message)
1152
+ say(`started centralu serve in the background (${how}, pid ${started.pid ?? 'unknown'}); waiting for its host`)
1153
+
1154
+ let exited
1155
+ started.exited?.then((c) => (exited = c ?? 'unknown'))
1156
+ const t0 = Date.now()
1157
+ while (Date.now() - t0 < timeoutMs) {
1158
+ if (await answered(false)) return 0
1159
+ const why = tail(log)
1160
+ if (exited !== undefined) return fail('exited', `centralu serve exited (${exited}) before its host answered${why ? `:\n${why}` : ''}`)
1161
+ const gone = started.alive?.() === false
1162
+ if (how !== 'setsid' && !existsSync(log) && (gone || Date.now() - t0 > noStartMs)) {
1163
+ // WMI said yes, and nothing ran: the process was stopped before Node started, or hangs (`startDetached`)
1164
+ if (!gone) await started.stop?.()
1165
+ return fail('wmi_blocked', wmiBlockedMessage(gone ? `process ${started.pid} ended before it started` : `nothing started within ${Math.round(noStartMs / 1000)} s`))
1166
+ }
1167
+ if (gone) return fail('exited', `centralu serve exited before its host answered${why ? `:\n${why}` : ''}`)
1168
+ await sleep(250)
1169
+ }
1170
+ const why = tail(log)
1171
+ return fail('timeout', `the host did not answer within ${Math.round(timeoutMs / 1000)} s${why ? `:\n${why}` : ''}`)
1172
+ }
1173
+
1174
+ /**
1175
+ * The launcher `--detach` started: `runServe` writing to serve.log. The carried variables are
1176
+ * already in `env` (centralu.mjs merges them before it looks for the host).
1177
+ */
1178
+ export async function runDetachedChild({ spec, ...opts }) {
1179
+ let fd = null
1180
+ try {
1181
+ fd = openSync(spec.log, 'a')
1182
+ sink = (text) => {
1183
+ try {
1184
+ writeSync(fd, text)
1185
+ } catch {
1186
+ // nowhere left to say it
1187
+ }
1188
+ }
1189
+ } catch {
1190
+ // no log: say() keeps stderr, which nobody reads; --detach then reports the start as never run
1191
+ }
1192
+ say(`started in the background (pid ${process.pid}, Node ${process.version})`)
1193
+ try {
1194
+ return await runServe({ ...opts, logFd: fd })
1195
+ } catch (e) {
1196
+ say(e?.message ?? String(e))
1197
+ return 1
1198
+ }
1199
+ }
1200
+
1201
+ // ── --stop ──────────────────────────────────────────────────────────────────────────────────
1202
+
1203
+ /**
1204
+ * Asks a running host to stop (`host.stop`, an additive RPC): a hello with the token, then the call.
1205
+ * `{ ok: true }` once the host took it, `{ ok: false, message, protocolVersion? }` otherwise; the
1206
+ * last names the host's protocol when the hello was refused for it, so the caller can ask again.
1207
+ */
1208
+ export function askHostToStop({ port, token, protocolVersion, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
1209
+ if (typeof WebSocketImpl !== 'function') return Promise.resolve({ ok: false, message: `Node ${process.version} has no WebSocket to ask with` })
1210
+ return new Promise((resolve) => {
1211
+ let ws = null
1212
+ let settled = false
1213
+ const done = (result) => {
1214
+ if (settled) return
1215
+ settled = true
1216
+ clearTimeout(timer)
1217
+ try {
1218
+ ws?.close()
1219
+ } catch {
1220
+ // already closed
1221
+ }
1222
+ resolve(result)
1223
+ }
1224
+ const timer = setTimeout(() => done({ ok: false, message: 'no answer in time' }), timeoutMs)
1225
+ try {
1226
+ ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
1227
+ } catch (e) {
1228
+ done({ ok: false, message: e.message })
1229
+ return
1230
+ }
1231
+ ws.onopen = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
1232
+ ws.onmessage = (ev) => {
1233
+ let m
1234
+ try {
1235
+ m = JSON.parse(String(ev.data))
1236
+ } catch {
1237
+ return
1238
+ }
1239
+ if (m?.kind === 'hello_ok') ws.send(JSON.stringify({ kind: 'rpc', id: 'stop', method: 'host.stop', params: {} }))
1240
+ else if (m?.kind === 'res' && m.id === 'stop') done(m.ok ? { ok: true } : { ok: false, message: m.error?.message ?? 'refused' })
1241
+ else if (m?.kind === 'res' && m.ok === false) {
1242
+ const pv = m.error?.code === 'version_mismatch' ? (m.error?.data?.protocolVersion ?? mismatchServerVersion(m.error.message)) : null
1243
+ done({ ok: false, message: m.error?.message ?? 'refused', ...(Number.isInteger(pv) ? { protocolVersion: pv } : {}) })
1244
+ }
1245
+ }
1246
+ ws.onerror = () => done({ ok: false, message: 'could not connect' })
1247
+ ws.onclose = () => done({ ok: false, message: 'the connection closed' })
1248
+ })
1249
+ }
1250
+
1251
+ async function waitUntil(pred, ms) {
1252
+ const t0 = Date.now()
1253
+ while (Date.now() - t0 < ms) {
1254
+ if (await pred()) return true
1255
+ await sleep(200)
1256
+ }
1257
+ return pred()
1258
+ }
1259
+
1260
+ /**
1261
+ * `centralu serve --stop` (docs/plans/remote-hub.md §10.4, S10): asks the running host to stop over
1262
+ * its own socket and waits until it has, so its shutdown runs (its agents, terminals and app
1263
+ * processes stopped in order, the store closed). Measured on Windows: killing the WMI-started
1264
+ * launcher ended the host 143 ms later with no "shutting down" line, because the host sits in
1265
+ * libuv's kill-on-close job object; so on Windows killing is the last resort, not the way.
1266
+ *
1267
+ * Then, only for a host that did not go: posix SIGTERM to the launcher (which passes it to the host,
1268
+ * the same clean stop), and on Windows ending the host. A host from before `host.stop` gets that
1269
+ * too. The launcher ends with its host; one still there afterwards is ended.
1270
+ *
1271
+ * Only the processes serve.pid and host.lock name, and only while they agree with each other and
1272
+ * the host answered with this folder's token: a stale pid is never signalled.
1273
+ *
1274
+ * stdout: one line, `{ v: 1, stop: { ok, wasRunning, how?, message? } }`.
1275
+ */
1276
+ export async function runStop({
1277
+ env,
1278
+ home,
1279
+ entry,
1280
+ platform = process.platform,
1281
+ timeoutMs = 30_000,
1282
+ graceMs = 15_000,
1283
+ kill = (pid, sig) => process.kill(pid, sig),
1284
+ write = (line) => process.stdout.write(line),
1285
+ }) {
1286
+ const dataDir = serveDataDir(env, home)
1287
+ const report = (stop) => {
1288
+ if (stop.message) say(stop.message)
1289
+ write(`${JSON.stringify({ v: 1, stop })}\n`)
1290
+ return stop.ok ? 0 : 1
1291
+ }
1292
+ const state = readServeState(dataDir)
1293
+ if (!state) return report({ ok: true, wasRunning: false, message: `no centralu serve has run for ${dataDir}` })
1294
+ const port = state.port ?? DEFAULT_SERVE_PORT
1295
+ const installed = protocolVersionOf(entry) ?? 0
1296
+ const probe = () => probeHost({ port, token: state.token, protocolVersion: installed })
1297
+ const first = await probe()
1298
+ if (!first.running) return report({ ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}` })
1299
+
1300
+ const hostPid = lockHolderPid(dataDir)
1301
+ const launcher = readServePid(dataDir)
1302
+ // serve.pid is only believed when it names the host that holds the folder now
1303
+ const launcherPid = launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
1304
+ const down = async () => !(await probe()).running && !(hostPid && isAlive(hostPid))
1305
+
1306
+ let asked = await askHostToStop({ port, token: state.token, protocolVersion: first.protocolVersion ?? installed })
1307
+ if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, token: state.token, protocolVersion: asked.protocolVersion })
1308
+ let how = 'asked'
1309
+ let stopped = asked.ok && (await waitUntil(down, timeoutMs))
1310
+ let note = asked.ok ? null : `the host did not take the request to stop (${asked.message})`
1311
+ if (!stopped) {
1312
+ const target = platform !== 'win32' && launcherPid ? launcherPid : hostPid
1313
+ if (target) {
1314
+ how = platform === 'win32' ? 'ended' : 'signal'
1315
+ try {
1316
+ kill(target, 'SIGTERM')
1317
+ } catch {
1318
+ // gone in the meantime
1319
+ }
1320
+ stopped = await waitUntil(down, platform === 'win32' ? 5_000 : graceMs)
1321
+ if (!stopped && hostPid && isAlive(hostPid)) {
1322
+ how = 'ended'
1323
+ try {
1324
+ kill(hostPid, 'SIGKILL')
1325
+ } catch {
1326
+ // gone in the meantime
1327
+ }
1328
+ stopped = await waitUntil(down, 5_000)
1329
+ }
1330
+ }
1331
+ if (how === 'ended') note = `${note ? `${note}; ` : ''}ended the host without its shutdown`
1332
+ }
1333
+ if (launcherPid && launcherPid !== process.pid && !(await waitUntil(() => !isAlive(launcherPid), 5_000))) {
1334
+ try {
1335
+ kill(launcherPid, 'SIGKILL')
1336
+ } catch {
1337
+ // gone in the meantime
1338
+ }
1339
+ }
1340
+ if (!stopped) return report({ ok: false, wasRunning: true, how, message: `the host on 127.0.0.1:${port} is still running${note ? ` (${note})` : ''}` })
1341
+ return report({ ok: true, wasRunning: true, how, ...(note ? { message: note } : {}) })
1342
+ }
1343
+
1344
+ // ── --autostart ─────────────────────────────────────────────────────────────────────────────
1345
+
1346
+ /**
1347
+ * What `--autostart on` records in the data folder: the entry it wrote, so `--connection` can say
1348
+ * whether this machine starts its host at boot without asking systemd or Task Scheduler at every
1349
+ * link start. `--autostart status` asks them, and corrects this file when it disagrees.
1350
+ */
1351
+ export const AUTOSTART_FILE = 'autostart.json'
1352
+
1353
+ /** The variables the entry carries to the serve it starts: the data folder, and a host entry (development) */
1354
+ export const AUTOSTART_ENV = ['CC_DATA_DIR', 'CENTRALU_HOST_ENTRY']
1355
+
1356
+ /**
1357
+ * How this machine would start `serve` at boot (docs/plans/remote-hub.md §10.4, owner decision 4),
1358
+ * or null where it offers none:
1359
+ *
1360
+ * systemd Linux: a `systemd --user` unit, enabled for `default.target`, plus lingering so the
1361
+ * user's manager starts at boot rather than at the first login
1362
+ * task Windows: a per-user scheduled task at sign-in (no administrator needed). Also inside
1363
+ * WSL: nothing in a stopped distro runs, so the Windows side has to start it, through
1364
+ * `wsl.exe`, which then holds the distro for as long as the host runs
1365
+ *
1366
+ * WSL with interop off has no way to the Windows side, and a Linux without systemd (or macOS) has
1367
+ * no user unit; those say so instead.
1368
+ */
1369
+ export function autostartHow(platform, env, { interop = wslInterop, systemd = () => existsSync('/run/systemd/system') } = {}) {
1370
+ if (platform === 'win32') return 'task'
1371
+ if (platform !== 'linux') return null
1372
+ if (env.WSL_DISTRO_NAME) return interop() ? 'task' : null
1373
+ return systemd() ? 'systemd' : null
1374
+ }
1375
+
1376
+ /** Why `autostartHow` found nothing, in one sentence */
1377
+ export function autostartUnsupported(platform, env) {
1378
+ if (platform === 'linux' && env.WSL_DISTRO_NAME) {
1379
+ return 'Starting Centralu with WSL needs a Windows scheduled task, and this distro cannot run Windows programs (WSL interop is off)'
1380
+ }
1381
+ 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'
1382
+ 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`
1383
+ }
1384
+
1385
+ /**
1386
+ * A folder path as a comparable key on `platform`: either separator as `/`, `.` and `..` folded, no
1387
+ * trailing separator, and lower case on Windows, whose paths do not differ by case
1388
+ */
1389
+ export function pathKey(p, platform) {
1390
+ let s = posix.normalize(String(p).replaceAll('\\', '/'))
1391
+ if (s.length > 1) s = s.replace(/\/+$/, '')
1392
+ return platform === 'win32' ? s.toLowerCase() : s
1393
+ }
1394
+
1395
+ /**
1396
+ * The entry's name. One per data folder: the default one gets the plain name, any other (a second
1397
+ * install, a test's temporary folder) a suffix from its path, so they never replace each other. A
1398
+ * Windows laptop linked twice, as Windows and as a WSL distro, gets two tasks.
1399
+ *
1400
+ * Whether `dataDir` is the default is read the same way whatever OS runs this (`pathKey`), not with
1401
+ * this Node's `join`: the folder may be written with either separator (`CC_DATA_DIR=C:/x` on Windows),
1402
+ * and Windows paths do not differ by case, so `C:\Users\Me\.centralu` is the default folder too.
1403
+ */
1404
+ export function autostartName(how, { dataDir, home, env, platform = process.platform }) {
1405
+ const key = pathKey(dataDir, platform)
1406
+ const own = key === pathKey(`${home}/.centralu`, platform) ? '' : createHash('sha256').update(key).digest('hex').slice(0, 8)
1407
+ if (how === 'systemd') return `centralu-serve${own ? `-${own}` : ''}.service`
1408
+ const distro = env.WSL_DISTRO_NAME
1409
+ if (distro && !/^[A-Za-z0-9._-]{1,64}$/.test(distro)) throw new Error(`not a WSL distro name: ${distro}`)
1410
+ return `Centralu host${distro ? ` (WSL ${distro})` : ''}${own ? ` ${own}` : ''}`
1411
+ }
1412
+
1413
+ /**
1414
+ * What the entry runs: the managed launcher when the app installed Centralu here over ssh (its text
1415
+ * never changes and it reads `current`, so an update needs no new entry), else the launcher `serve`
1416
+ * keeps for an npm install. The order is the hub's lookup (`connectionCommand` in links/tunnel.ts).
1417
+ */
1418
+ export function autostartLauncher(dataDir, platform, exists = existsSync) {
1419
+ const file = platform === 'win32' ? 'centralu.cmd' : 'centralu'
1420
+ for (const p of [join(remoteLayout(dataDir).bin, file), join(dataDir, 'bin', file)]) if (exists(p)) return p
1421
+ return null
1422
+ }
1423
+
1424
+ /** One value for a unit file, double-quoted; `$` doubled where systemd would expand it (ExecStart) */
1425
+ function systemdQuote(value, { dollars = false } = {}) {
1426
+ const s = String(value)
1427
+ if (/[\r\n\0]/.test(s)) throw new Error(`cannot put ${JSON.stringify(s)} in a systemd unit`)
1428
+ let q = s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')
1429
+ if (dollars) q = q.replaceAll('$', '$$$$')
1430
+ return `"${q}"`
1431
+ }
1432
+
1433
+ /**
1434
+ * The `systemd --user` unit (docs/agent-host.md §4.7 has the hand-written one this follows).
1435
+ *
1436
+ * - `serve` in the foreground: systemd is what keeps it outside any session.
1437
+ * - `KillMode=mixed`: a stop sends SIGTERM to the launcher only, which passes one to the host, which
1438
+ * stops its agents in order; the rest of the group is killed only after `TimeoutStopSec`.
1439
+ * - `Restart=on-failure`, but bounded: a host that fails at every start (a port another program
1440
+ * holds, a store from a newer build) is left failed after five tries in five minutes rather than
1441
+ * started forever. A clean stop (`serve --stop`, as an update does) exits 0 and stays stopped.
1442
+ */
1443
+ export function systemdUnit({ launcher, env }) {
1444
+ return [
1445
+ '# Written by `centralu serve --autostart on`; `centralu serve --autostart off` removes it.',
1446
+ '[Unit]',
1447
+ 'Description=Centralu host (centralu serve)',
1448
+ 'StartLimitIntervalSec=300',
1449
+ 'StartLimitBurst=5',
1450
+ '',
1451
+ '[Service]',
1452
+ 'Type=simple',
1453
+ ...AUTOSTART_ENV.filter((k) => env[k]).map((k) => `Environment=${systemdQuote(`${k}=${env[k]}`)}`),
1454
+ `ExecStart=${systemdQuote(launcher, { dollars: true })} serve`,
1455
+ 'KillMode=mixed',
1456
+ 'TimeoutStopSec=30',
1457
+ 'Restart=on-failure',
1458
+ 'RestartSec=10',
1459
+ '',
1460
+ '[Install]',
1461
+ 'WantedBy=default.target',
1462
+ '',
1463
+ ].join('\n')
1464
+ }
1465
+
1466
+ /** A PowerShell expression for `s`: base64 decoded there, so no quote reaches PowerShell's parser */
1467
+ function psText(s) {
1468
+ return `([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(String(s), 'utf8').toString('base64')}')))`
1469
+ }
1470
+
1471
+ /**
1472
+ * The PowerShell the scheduled task runs at sign-in, in a hidden window: `serve` in the foreground,
1473
+ * for as long as it runs. Not `--detach`: a task is not an ssh session, so nothing ends it with one,
1474
+ * and it needs no WMI, which some machines block (decision 3).
1475
+ *
1476
+ * Windows the launcher itself
1477
+ * WSL `wsl.exe -d <distro> -u <user> --exec <launcher> serve`: that `wsl.exe` is a client of
1478
+ * the distro for as long as the host runs, so WSL does not stop it (§10.4)
1479
+ *
1480
+ * The variables of `AUTOSTART_ENV` are set in the task (Windows) or passed through `env` (WSL, where
1481
+ * the Windows side's environment does not reach the distro).
1482
+ */
1483
+ export function taskActionScript({ how, launcher, env, distro = null, user = null }) {
1484
+ const carried = AUTOSTART_ENV.filter((k) => env[k])
1485
+ if (how === 'wmi') return [...carried.map((k) => `$env:${k} = ${psText(env[k])}`), `& ${psText(launcher)} serve`].join('\n')
1486
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(distro ?? '')) throw new Error(`not a WSL distro name: ${distro}`)
1487
+ if (!/^[A-Za-z_][A-Za-z0-9_.-]{0,31}$/.test(user ?? '')) throw new Error(`cannot name the user ${user} to wsl.exe`)
1488
+ const args = ['-d', distro, '-u', user, '--cd', '~', '--exec', ...(carried.length ? ['/usr/bin/env', ...carried.map((k) => `${k}=${env[k]}`)] : []), launcher, 'serve']
1489
+ for (const a of args) if (/["\r\n\0]/.test(a)) throw new Error(`cannot pass ${JSON.stringify(a)} on a Windows command line`)
1490
+ return [`$a = @(${args.map(psText).join(', ')})`, "& (Join-Path $env:SystemRoot 'System32\\wsl.exe') @a"].join('\n')
1491
+ }
1492
+
1493
+ const TASK_WORD = 'CENTRALU-AUTOSTART'
1494
+
1495
+ /**
1496
+ * The PowerShell that registers, removes or reads the scheduled task, printing one `CENTRALU-AUTOSTART`
1497
+ * line. Registered for this user only, at this user's sign-in, run only while they are signed in
1498
+ * (`Interactive`): the kind of task a user may create without an administrator. On a laptop, it runs
1499
+ * on battery too, and with no time limit (the default ends a task after 72 hours).
1500
+ */
1501
+ export function taskScript(action, { name, run = '' }) {
1502
+ const head = ["$ErrorActionPreference = 'Stop'", '[Console]::OutputEncoding = [Text.Encoding]::UTF8', `$name = ${psText(name)}`]
1503
+ const fail = `} catch { "${TASK_WORD}-ERROR $($_.Exception.Message -replace '\\s+', ' ')" }`
1504
+ const find = "$t = Get-ScheduledTask -TaskName $name -TaskPath '\\' -ErrorAction SilentlyContinue"
1505
+ if (action === 'status') return [...head, 'try {', ` ${find}`, ` if ($t) { "${TASK_WORD} state $($t.State)" } else { '${TASK_WORD} state none' }`, fail].join('\n')
1506
+ if (action === 'off') {
1507
+ return [...head, 'try {', ` ${find}`, " if ($t) { Unregister-ScheduledTask -TaskName $name -TaskPath '\\' -Confirm:$false }", ` '${TASK_WORD} off'`, fail].join('\n')
1508
+ }
1509
+ const encoded = Buffer.from(run, 'utf16le').toString('base64')
1510
+ return [
1511
+ ...head,
1512
+ 'try {',
1513
+ " $exe = Join-Path $env:SystemRoot 'System32\\WindowsPowerShell\\v1.0\\powershell.exe'",
1514
+ ` $arg = '-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encoded}'`,
1515
+ ' $user = if ($env:USERDOMAIN) { "$env:USERDOMAIN\\$env:USERNAME" } else { $env:USERNAME }',
1516
+ ' $a = New-ScheduledTaskAction -Execute $exe -Argument $arg',
1517
+ ' $tr = New-ScheduledTaskTrigger -AtLogOn -User $user',
1518
+ ' $p = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited',
1519
+ ' $s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew',
1520
+ ` 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`,
1521
+ ` '${TASK_WORD} on'`,
1522
+ fail,
1523
+ ].join('\n')
1524
+ }
1525
+
1526
+ /** What a task script printed: `{ ok: true, word }` (`on`, `off`, or the state after `state`) or `{ ok: false, message }` */
1527
+ export function parseTaskAnswer(stdout) {
1528
+ const text = String(stdout ?? '')
1529
+ const e = new RegExp(`${TASK_WORD}-ERROR (.*)`).exec(text)
1530
+ if (e) return { ok: false, message: e[1].trim() }
1531
+ const m = new RegExp(`${TASK_WORD} (?:state )?(\\S+)`).exec(text)
1532
+ if (m) return { ok: true, word: m[1] }
1533
+ return { ok: false, message: 'PowerShell did not answer' }
1534
+ }
1535
+
1536
+ /** `{ how, name, linger }` from `AUTOSTART_FILE`, or null */
1537
+ export function readAutostartMark(dataDir) {
1538
+ try {
1539
+ const v = JSON.parse(readFileSync(join(dataDir, AUTOSTART_FILE), 'utf8'))
1540
+ if ((v?.how !== 'systemd' && v?.how !== 'task') || typeof v.name !== 'string') return null
1541
+ return { how: v.how, name: v.name, linger: typeof v.linger === 'boolean' ? v.linger : null }
1542
+ } catch {
1543
+ return null
1544
+ }
1545
+ }
1546
+
1547
+ function writeAutostartMark(dataDir, mark) {
1548
+ mkdirSync(dataDir, { recursive: true })
1549
+ const file = join(dataDir, AUTOSTART_FILE)
1550
+ const tmp = `${file}.${process.pid}.tmp`
1551
+ writeFileSync(tmp, `${JSON.stringify({ v: 1, ...mark, at: Date.now() })}\n`)
1552
+ renameSync(tmp, file)
1553
+ }
1554
+
1555
+ function clearAutostartMark(dataDir) {
1556
+ rmSync(join(dataDir, AUTOSTART_FILE), { force: true })
1557
+ }
1558
+
1559
+ /**
1560
+ * The connection line's `autostart` field (additive): `{ on, how, linger }`. `how` is what this
1561
+ * machine would use (null where it offers nothing), `on` whether `--autostart on` wrote an entry of
1562
+ * that kind, `linger` (systemd only) whether the user's manager starts at boot. From the mark only:
1563
+ * this runs at every link start.
1564
+ */
1565
+ export function autostartInfo(dataDir, platform, env, how = autostartHow(platform, env)) {
1566
+ const mark = readAutostartMark(dataDir)
1567
+ const on = how !== null && mark?.how === how
1568
+ return { on, how, linger: on && how === 'systemd' ? mark.linger : null }
1569
+ }
1570
+
1571
+ /** The sentence for a user manager that starts only at login (`loginctl enable-linger` refused) */
1572
+ export function lingerMessage(user) {
1573
+ return `systemd starts it when ${user} logs in, not at boot, until an administrator runs \`sudo loginctl enable-linger ${user}\` there`
1574
+ }
1575
+
1576
+ /** systemctl --user over ssh needs the user's runtime folder; a session without pam_systemd may not name it */
1577
+ function userManagerEnv(env) {
1578
+ if (env.XDG_RUNTIME_DIR || typeof process.getuid !== 'function') return env
1579
+ const dir = `/run/user/${process.getuid()}`
1580
+ return existsSync(dir) ? { ...env, XDG_RUNTIME_DIR: dir } : env
1581
+ }
1582
+
1583
+ async function systemdAutostart(action, { dataDir, env, home, name, run, user }) {
1584
+ const dir = join(env.XDG_CONFIG_HOME || join(home, '.config'), 'systemd', 'user')
1585
+ const file = join(dir, name)
1586
+ const ctlEnv = userManagerEnv(env)
1587
+ const ctl = (...args) => run('systemctl', ['--user', ...args], ctlEnv)
1588
+ const lingers = async () => {
1589
+ const r = await run('loginctl', ['show-user', user, '--property=Linger', '--value'], ctlEnv)
1590
+ return r.code === 0 ? r.stdout.trim() === 'yes' : null
1591
+ }
1592
+ const base = { ok: true, how: 'systemd' }
1593
+ if (action === 'status') {
1594
+ const on = existsSync(file) && (await ctl('is-enabled', name)).stdout.trim() === 'enabled'
1595
+ const linger = on ? await lingers() : null
1596
+ if (on) writeAutostartMark(dataDir, { how: 'systemd', name, linger })
1597
+ else clearAutostartMark(dataDir)
1598
+ return { ...base, on, linger }
1599
+ }
1600
+ if (action === 'off') {
1601
+ if (existsSync(file)) {
1602
+ await ctl('disable', name)
1603
+ rmSync(file, { force: true })
1604
+ // Left by an `enable` whose `disable` did not reach the manager; harmless, but not ours to leave
1605
+ rmSync(join(dir, 'default.target.wants', name), { force: true })
1606
+ await ctl('daemon-reload')
1607
+ }
1608
+ clearAutostartMark(dataDir)
1609
+ return { ...base, on: false, linger: null }
1610
+ }
1611
+ const launcher = autostartLauncher(dataDir, 'linux')
1612
+ if (!launcher) return { ...base, ok: false, on: false, linger: null, message: `there is no Centralu launcher in ${dataDir} to start at boot` }
1613
+ mkdirSync(dir, { recursive: true })
1614
+ const tmp = `${file}.${process.pid}.tmp`
1615
+ writeFileSync(tmp, systemdUnit({ launcher, env }))
1616
+ renameSync(tmp, file)
1617
+ let r = await ctl('daemon-reload')
1618
+ if (r.code === 0) r = await ctl('enable', name)
1619
+ if (r.code !== 0) {
1620
+ rmSync(file, { force: true })
1621
+ await ctl('daemon-reload')
1622
+ clearAutostartMark(dataDir)
1623
+ const why = String(r.stderr).trim().split(/\r?\n/).at(-1) || `exit ${r.code}`
1624
+ return { ...base, ok: false, on: false, linger: null, message: `systemd did not take the unit: ${why}` }
1625
+ }
1626
+ let linger = await lingers()
1627
+ if (linger === false) {
1628
+ await run('loginctl', ['--no-ask-password', 'enable-linger', user], ctlEnv)
1629
+ linger = await lingers()
1630
+ }
1631
+ writeAutostartMark(dataDir, { how: 'systemd', name, linger })
1632
+ return { ...base, on: true, linger, ...(linger === true ? {} : { message: lingerMessage(user) }) }
1633
+ }
1634
+
1635
+ async function taskAutostart(action, { dataDir, env, name, run, user, platform, interop }) {
1636
+ const detach = detachHow(platform, env, interop)
1637
+ const ps = windowsPowershell(detach, env)
1638
+ const base = { ok: true, how: 'task', linger: null }
1639
+ if (!ps) return { ...base, ok: false, on: false, message: 'Windows PowerShell is not reachable from this WSL distro' }
1640
+ const ask = async (script) => {
1641
+ const r = await run(ps, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], env)
1642
+ const a = parseTaskAnswer(r.stdout)
1643
+ return a.ok || r.stdout.trim() || !r.stderr.trim() ? a : { ok: false, message: r.stderr.trim().split(/\r?\n/).at(-1) }
1644
+ }
1645
+ if (action === 'status') {
1646
+ const a = await ask(taskScript('status', { name }))
1647
+ if (!a.ok) return { ...base, ok: false, on: readAutostartMark(dataDir)?.how === 'task', message: `Task Scheduler did not answer: ${a.message}` }
1648
+ const on = a.word !== 'none' && a.word !== 'Disabled'
1649
+ if (on) writeAutostartMark(dataDir, { how: 'task', name, linger: null })
1650
+ else clearAutostartMark(dataDir)
1651
+ return { ...base, on }
1652
+ }
1653
+ if (action === 'off') {
1654
+ const a = await ask(taskScript('off', { name }))
1655
+ if (!a.ok) return { ...base, ok: false, on: true, message: `Task Scheduler did not remove the task "${name}": ${a.message}` }
1656
+ clearAutostartMark(dataDir)
1657
+ return { ...base, on: false }
1658
+ }
1659
+ const launcher = autostartLauncher(dataDir, platform)
1660
+ if (!launcher) return { ...base, ok: false, on: false, message: `there is no Centralu launcher in ${dataDir} to start at sign-in` }
1661
+ const runScript = taskActionScript({ how: detach, launcher, env, distro: env.WSL_DISTRO_NAME ?? null, user })
1662
+ const a = await ask(taskScript('on', { name, run: runScript }))
1663
+ if (!a.ok) return { ...base, ok: false, on: false, message: `Task Scheduler did not take the task: ${a.message}` }
1664
+ writeAutostartMark(dataDir, { how: 'task', name, linger: null })
1665
+ return { ...base, on: true }
1666
+ }
1667
+
1668
+ /**
1669
+ * `centralu serve --autostart on|off|status` (docs/plans/remote-hub.md §10.4, owner decision 4: per
1670
+ * machine, off by default). The hub runs it over ssh for the Settings → Machines toggle, and its
1671
+ * uninstall runs `off`. Turning it on or off never starts or stops a host: one runs already when the
1672
+ * hub asks (the link started it), and the entry is for the next boot.
1673
+ *
1674
+ * stdout: one line, `{ v: 1, autostart: { ok, on, how, linger, message? } }`; `how` null where this
1675
+ * machine offers nothing (`autostartHow`). `run` (a command runner) and `how` are parameters so
1676
+ * tests never reach the real systemd or Task Scheduler.
1677
+ */
1678
+ export async function runAutostart({
1679
+ env,
1680
+ home,
1681
+ action,
1682
+ cliPath,
1683
+ platform = process.platform,
1684
+ interop = wslInterop,
1685
+ how = autostartHow(platform, env, { interop }),
1686
+ run = runCaptured,
1687
+ user = userInfo().username,
1688
+ write = (line) => process.stdout.write(line),
1689
+ }) {
1690
+ const dataDir = serveDataDir(env, home)
1691
+ // The entry of an npm install runs the launcher serve keeps; make sure it is there and current
1692
+ keepLauncher(dataDir, cliPath, env)
1693
+ const report = (a) => {
1694
+ if (a.message) say(a.message)
1695
+ write(`${JSON.stringify({ v: 1, autostart: a })}\n`)
1696
+ return a.ok ? 0 : 1
1697
+ }
1698
+ if (how === null) {
1699
+ clearAutostartMark(dataDir)
1700
+ return report({ ok: action !== 'on', on: false, how: null, linger: null, ...(action === 'off' ? {} : { message: autostartUnsupported(platform, env) }) })
1701
+ }
1702
+ try {
1703
+ const name = autostartName(how, { dataDir, home, env, platform })
1704
+ const opts = { dataDir, env, home, name, run, user, platform, interop }
1705
+ return report(how === 'systemd' ? await systemdAutostart(action, opts) : await taskAutostart(action, opts))
1706
+ } catch (e) {
1707
+ return report({ ok: false, on: readAutostartMark(dataDir)?.how === how, how, linger: null, message: e?.message ?? String(e) })
1708
+ }
1709
+ }