centralu 0.1.0-beta.4 → 0.1.0-beta.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,289 @@
1
+ /**
2
+ * `centralu serve --detach` and `--stop`: starting serve outside the ssh session that asked, and
3
+ * stopping it in order (docs/plans/remote-hub.md §10.4).
4
+ */
5
+ import { spawn } from 'node:child_process'
6
+ import { existsSync, openSync, rmSync, writeFileSync, writeSync } from 'node:fs'
7
+ import { userInfo } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import { answerLine, isAlive, say, sayTo, runCaptured, tail, sleep } from './serve-util.mjs'
10
+ import { SERVE_LOG_FILE, serveDataDir, ensureServeState, markStopped, rotateServeLog } from './serve-files.mjs'
11
+ import { askPowershell, detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, windowsPowershell, windowsTool, wslDistro, wslUser } from './serve-windows.mjs'
12
+ import { CHILD_FLAG } from './serve-args.mjs'
13
+ import { startInSignIn } from './serve-autostart.mjs'
14
+ import { stopServedHost } from './serve-handover.mjs'
15
+ import { connectionLine, serveFrom } from './serve-host.mjs'
16
+ import { describe, runServe, serveFolder } from './serve-run.mjs'
17
+
18
+ /**
19
+ * What a detached launcher needs from the `--detach` that started it. A process created through WMI
20
+ * does not inherit this environment: it gets the account's default one. Everything else it needs
21
+ * (the token, the port) is in the data folder. Never a secret.
22
+ */
23
+ export const CARRIED_ENV = ['CC_DATA_DIR', 'CENTRALU_HOST_ENTRY', 'CENTRALU_MANAGED', 'CENTRALU_STARTED_FROM']
24
+
25
+ /**
26
+ * `start` is how the launcher was started and why (`through WMI: <reason>`), which it writes into its first serve.log
27
+ * line, so a host that ended up outside the Windows sign-in says why (#1223)
28
+ */
29
+ export function encodeChildSpec({ env, log, start = null }) {
30
+ const carried = {}
31
+ for (const k of CARRIED_ENV) if (typeof env[k] === 'string' && env[k]) carried[k] = env[k]
32
+ return Buffer.from(JSON.stringify({ log, env: carried, ...(start ? { start } : {}) }), 'utf8').toString('base64url')
33
+ }
34
+
35
+ /** `{ log, env, start? }` from `--detached-child=`, only the carried variables; null when it is not one */
36
+ export function decodeChildSpec(raw) {
37
+ try {
38
+ const v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'))
39
+ if (typeof v?.log !== 'string' || !v.log) return null
40
+ const env = {}
41
+ for (const k of CARRIED_ENV) if (typeof v.env?.[k] === 'string') env[k] = v.env[k]
42
+ return { log: v.log, env, ...(typeof v.start === 'string' && v.start ? { start: v.start } : {}) }
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Starts the launcher outside this session (`detachHow`). `{ ok: true, pid, exited?, alive? }` once
50
+ * something was created, `{ ok: false, blocked, message }` when nothing was: `blocked` is the WMI
51
+ * refusal the hub falls back from.
52
+ */
53
+ export async function startDetached({ how, execPath, args, env, cwd }) {
54
+ if (how === 'setsid') {
55
+ return await new Promise((resolve) => {
56
+ let child
57
+ try {
58
+ child = spawn(execPath, args, { detached: true, stdio: 'ignore', cwd, env, windowsHide: true })
59
+ } catch (e) {
60
+ return resolve({ ok: false, blocked: false, message: `could not start ${execPath}: ${e.message}` })
61
+ }
62
+ child.once('error', (e) => resolve({ ok: false, blocked: false, message: `could not start ${execPath}: ${e.message}` }))
63
+ child.once('spawn', () => {
64
+ const exited = new Promise((r) => child.once('exit', (code, signal) => r(signal ?? code)))
65
+ child.unref()
66
+ resolve({ ok: true, pid: child.pid, exited })
67
+ })
68
+ })
69
+ }
70
+ const ps = windowsPowershell(how, env)
71
+ if (!ps) return { ok: false, blocked: true, message: wmiBlockedMessage('Windows PowerShell is not reachable from this WSL distro') }
72
+ let commandLine
73
+ try {
74
+ if (how === 'wmi') commandLine = [execPath, ...args].map(windowsArg).join(' ')
75
+ else {
76
+ const distro = wslDistro(env.WSL_DISTRO_NAME ?? '')
77
+ const user = wslUser(userInfo().username)
78
+ commandLine = ['wsl.exe', '-d', distro, '-u', user, '--cd', cwd, '--exec', execPath, ...args].map(windowsArg).join(' ')
79
+ }
80
+ } catch (e) {
81
+ return { ok: false, blocked: false, message: e.message }
82
+ }
83
+ const script = wmiScript({ commandLine, cwd: how === 'wmi' ? cwd : null })
84
+ const answer = await askPowershell(ps, script, env, parseWmiAnswer)
85
+ if (!answer.ok) return { ok: false, blocked: true, message: wmiBlockedMessage(answer.message) }
86
+ // A Windows pid can be watched from Windows; from inside WSL it names nothing here
87
+ /*
88
+ * Measured on a Windows 11 laptop (2026-10-08): a `wsl.exe` that WMI creates can hang without
89
+ * running anything in the distro (it starts a second wsl.exe and waits), while `wsl.exe --list`
90
+ * from the same context answers. `--detach` reports that as a start that never ran, and ends what
91
+ * it created rather than leave it waiting: this pid and its children, nothing else.
92
+ */
93
+ const taskkill = windowsTool(how, env, ['System32', 'taskkill.exe'])
94
+ const stop = taskkill ? () => runCaptured(taskkill, ['/PID', String(answer.pid), '/T', '/F'], env) : null
95
+ return { ok: true, pid: answer.pid, alive: how === 'wmi' ? () => isAlive(answer.pid) : null, stop }
96
+ }
97
+
98
+ /**
99
+ * `centralu serve --detach` (docs/plans/remote-hub.md §10.4, S9): starts `serve` outside this
100
+ * session (`detachHow`) and returns once its host answers a hello, so the caller, typically the
101
+ * hub over ssh, can connect at once.
102
+ *
103
+ * stdout carries one JSON line, read by the hub (`links/tunnel.ts`): the connection line with
104
+ * `hostRunning: true` and `detach: { ok: true, how, already }`, or `{ v: 1, detach: { ok: false,
105
+ * how, reason, message } }`. `reason` is `wmi_blocked` when WMI created nothing or created something
106
+ * that never ran, which the hub answers with a link-bound start; `exited` and `timeout` quote the
107
+ * end of serve.log. Exit codes do not survive Windows' ssh (agent-host.md §4.8), so the line is
108
+ * what counts; the exit code is 0 or 1 for a person at a prompt.
109
+ *
110
+ * `start` and the two waits are parameters so tests can stand in for WMI.
111
+ */
112
+ export async function runDetach({
113
+ env,
114
+ home,
115
+ entry,
116
+ version,
117
+ cliPath,
118
+ port = null,
119
+ from = null,
120
+ platform = process.platform,
121
+ execPath = process.execPath,
122
+ cwd = process.cwd(),
123
+ start = startDetached,
124
+ how = detachHow(platform, env),
125
+ signIn = startInSignIn,
126
+ signInMs = 20_000,
127
+ timeoutMs = 30_000,
128
+ noStartMs = 10_000,
129
+ write = (line) => process.stdout.write(line),
130
+ }) {
131
+ // Created here, so the token exists before the launcher reads it and `answered` reads it again
132
+ const { dataDir } = serveFolder({ env, home, cliPath, platform })
133
+ const fail = (reason, message) => answerLine(write, 'detach', { ok: false, how, reason, message })
134
+ const answered = async (already, as = how) => {
135
+ const d = await describe({ env, dataDir, state: ensureServeState(dataDir, platform), entry, version })
136
+ if (!d?.hostRunning) return false
137
+ write(`${connectionLine({ ...d, detach: { ok: true, how: as, already } })}\n`)
138
+ return true
139
+ }
140
+ if (await answered(true)) return 0
141
+
142
+ const log = join(dataDir, SERVE_LOG_FILE)
143
+ /*
144
+ * One start per log: the earlier ones are kept beside it, three of them (`rotateServeLog`, #1143), and its absence is
145
+ * how a start that never ran shows. So a log that cannot be moved aside is removed rather than left to read as a start
146
+ */
147
+ const freshLog = () => rotateServeLog(log)
148
+
149
+ /*
150
+ * Windows: in the person's sign-in when someone is signed in (#1224), through the scheduled task, and through WMI only
151
+ * when nobody is. WMI's process keeps this ssh session's network logon, where Git cannot use the credentials saved in
152
+ * Windows. A task-started host that does not answer in time is left to the lock: the WMI start below then either
153
+ * holds the folder or is refused by it, never a second host on it. Which way it went, and why, is one line of
154
+ * serve.log (#1223): the task's serve writes no serve.log, so that line is written here; a WMI launcher writes its own
155
+ */
156
+ let notInSignIn = null
157
+ if (how === 'wmi') {
158
+ const s = await signIn({ dataDir, env, home, platform })
159
+ if (s.started) {
160
+ const line = `started centralu serve in the Windows sign-in (the scheduled task "${s.name}"); waiting for its host`
161
+ say(line)
162
+ freshLog()
163
+ try {
164
+ writeFileSync(log, `[centralu serve] ${line}\n`)
165
+ } catch {
166
+ // the line on stderr is all there is
167
+ }
168
+ const t0 = Date.now()
169
+ while (Date.now() - t0 < signInMs) {
170
+ if (await answered(false, 'task')) return 0
171
+ await sleep(250)
172
+ }
173
+ notInSignIn = `the host the sign-in task "${s.name}" started did not answer within ${Math.round(signInMs / 1000)} s`
174
+ // The WMI launcher's first line says so; the task's line goes, so `serve.log.1` keeps the start before this one
175
+ rmSync(log, { force: true, maxRetries: 5 })
176
+ } else {
177
+ notInSignIn = signInMissed(s)
178
+ }
179
+ say(`${notInSignIn}: starting the host through WMI${s.reason === 'nosession' ? ', outside any sign-in, where Git cannot use the credentials saved in Windows' : ''}`)
180
+ }
181
+
182
+ freshLog()
183
+ // Where this was asked for from (#1223): the launcher runs outside this ssh session, so it is told
184
+ const asked = serveFrom(env, from)
185
+ const childEnv = asked ? { ...env, CENTRALU_STARTED_FROM: JSON.stringify(asked) } : env
186
+ const startedHow = `through ${how === 'wmi' ? 'WMI' : how === 'wsl' ? 'a WMI-created wsl.exe' : how}${notInSignIn ? `: ${notInSignIn}` : ''}`
187
+ const args = [cliPath, 'serve', ...(port !== null ? ['--port', String(port)] : []), `${CHILD_FLAG}${encodeChildSpec({ env: childEnv, log, start: startedHow })}`]
188
+ let started
189
+ try {
190
+ started = await start({ how, execPath, args, env, cwd })
191
+ } catch (e) {
192
+ return fail('failed', `could not start centralu serve in the background: ${e.message}`)
193
+ }
194
+ if (!started.ok) return fail(started.blocked ? 'wmi_blocked' : 'failed', started.message)
195
+ say(`started centralu serve in the background (${how}, pid ${started.pid ?? 'unknown'}); waiting for its host`)
196
+
197
+ let exited
198
+ started.exited?.then((c) => (exited = c ?? 'unknown'))
199
+ const t0 = Date.now()
200
+ while (Date.now() - t0 < timeoutMs) {
201
+ if (await answered(false)) return 0
202
+ const why = tail(log)
203
+ if (exited !== undefined) return fail('exited', `centralu serve exited (${exited}) before its host answered${why ? `:\n${why}` : ''}`)
204
+ const gone = started.alive?.() === false
205
+ if (how !== 'setsid' && !existsSync(log) && (gone || Date.now() - t0 > noStartMs)) {
206
+ // WMI said yes, and nothing ran: the process was stopped before Node started, or hangs (`startDetached`)
207
+ if (!gone) await started.stop?.()
208
+ return fail('wmi_blocked', wmiBlockedMessage(gone ? `process ${started.pid} ended before it started` : `nothing started within ${Math.round(noStartMs / 1000)} s`))
209
+ }
210
+ if (gone) return fail('exited', `centralu serve exited before its host answered${why ? `:\n${why}` : ''}`)
211
+ await sleep(250)
212
+ }
213
+ const why = tail(log)
214
+ return fail('timeout', `the host did not answer within ${Math.round(timeoutMs / 1000)} s${why ? `:\n${why}` : ''}`)
215
+ }
216
+
217
+ /** Why the host was not started in the Windows sign-in, from `startInSignIn`'s answer, for serve.log and stderr */
218
+ export function signInMissed(s) {
219
+ if (s.reason === 'nosession') {
220
+ const n = s.desktops
221
+ const unread = s.unreadable ? `, ${s.unreadable} of them with an owner WMI would not tell` : ''
222
+ const seen =
223
+ typeof n !== 'number'
224
+ ? 'no Explorer of this account in a desktop session'
225
+ : n === 0
226
+ ? 'no Explorer in any desktop session'
227
+ : `none of the ${n} Explorer${n === 1 ? '' : 's'} in desktop sessions is this account's${unread}`
228
+ return `no Windows sign-in of this user was found (${seen})`
229
+ }
230
+ // It travels on the launcher's command line: bounded
231
+ if (s.reason === 'error') return `the sign-in task failed: ${String(s.message).slice(0, 300)}`
232
+ if (s.reason === 'off') return 'the sign-in task is turned off (CENTRALU_SIGNIN_TASK=off)'
233
+ if (s.reason === 'no_launcher') return 'there is no launcher in the data folder for the sign-in task to run'
234
+ return `the sign-in task was not used (${s.reason})`
235
+ }
236
+
237
+ /**
238
+ * The launcher `--detach` started: `runServe` writing to serve.log. The carried variables are
239
+ * already in `env` (centralu.mjs merges them before it looks for the host).
240
+ */
241
+ export async function runDetachedChild({ spec, ...opts }) {
242
+ let fd = null
243
+ try {
244
+ fd = openSync(spec.log, 'a')
245
+ sayTo((text) => {
246
+ try {
247
+ writeSync(fd, text)
248
+ } catch {
249
+ // nowhere left to say it
250
+ }
251
+ })
252
+ } catch {
253
+ // no log: say() keeps stderr, which nobody reads; --detach then reports the start as never run
254
+ }
255
+ // One line saying how it was started and why (#1223), and what runs
256
+ say(`started in the background${spec.start ? ` ${spec.start}` : ''} (pid ${process.pid}, Node ${process.version})`)
257
+ try {
258
+ return await runServe({ ...opts, logFd: fd })
259
+ } catch (e) {
260
+ say(e?.message ?? String(e))
261
+ return 1
262
+ }
263
+ }
264
+
265
+ /**
266
+ * `centralu serve --stop` (docs/plans/remote-hub.md §10.4, S10): the running host stopped in order, over its own
267
+ * socket, and waited for (`stopServedHost` in serve-handover.mjs, which the hand-over into the sign-in shares, #1224).
268
+ *
269
+ * stdout: one line, `{ v: 1, stop: { ok, wasRunning, how?, message? } }`.
270
+ *
271
+ * A stop that went through is remembered in the data folder (`markStopped`, #1143), so a linked hub does not start the
272
+ * host again by itself; the next host that starts here removes the mark. Not for a hub's own stop, which starts the
273
+ * host again itself (an update, a rollback): the hub runs it with `CENTRALU_STOP_BY=hub` (links/tunnel.ts `stopCommand`)
274
+ */
275
+ export async function runStop({
276
+ env,
277
+ home,
278
+ entry,
279
+ platform = process.platform,
280
+ timeoutMs = 30_000,
281
+ graceMs = 15_000,
282
+ kill = (pid, sig) => process.kill(pid, sig),
283
+ write = (line) => process.stdout.write(line),
284
+ }) {
285
+ const dataDir = serveDataDir(env, home)
286
+ const stopped = await stopServedHost({ dataDir, entry, platform, timeoutMs, graceMs, kill })
287
+ if (stopped.ok && env.CENTRALU_STOP_BY !== 'hub') markStopped(dataDir, 'stop')
288
+ return answerLine(write, 'stop', stopped)
289
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * The files `centralu serve` keeps in the data folder: the token and port (serve.json, private to the
3
+ * user), the running launcher's pid (serve.pid), and the host's own lock (host.lock), read to tell a
4
+ * live host of this folder from a stale pid.
5
+ */
6
+ import { execFileSync } from 'node:child_process'
7
+ import { randomBytes } from 'node:crypto'
8
+ import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync, closeSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { isAlive, renameRetrying, replaceFile, sleepSync } from './serve-util.mjs'
11
+
12
+ /**
13
+ * The file in the data folder that holds the token and the port, mode 0600.
14
+ *
15
+ * The token is generated once, the first time either `serve` or `serve --connection` runs, and kept:
16
+ * the client stores it (in the OS secret store) and has to find the same one after the host
17
+ * restarts. The port is the one the last `serve` actually listened on, which is how
18
+ * `--connection`, a separate process, learns a `--port` given to `serve` in a systemd unit.
19
+ */
20
+ export const SERVE_STATE_FILE = 'serve.json'
21
+
22
+ /** What a detached serve writes: its own lines, and the host's until it is ready (host.log has the rest) */
23
+ export const SERVE_LOG_FILE = 'serve.log'
24
+
25
+ /** The running launcher's pid and port, so `--stop` can find it. Removed when it exits */
26
+ export const SERVE_PID_FILE = 'serve.pid'
27
+
28
+ /** How many earlier starts' serve.log are kept, as serve.log.1 (the last) to serve.log.3 */
29
+ export const SERVE_LOGS_KEPT = 3
30
+
31
+ /**
32
+ * Makes room for a new start's serve.log (#1143): the earlier ones move down a place, and the oldest past
33
+ * `SERVE_LOGS_KEPT` goes. A hub that starts a host again after it stopped makes at most three starts in ten minutes,
34
+ * so the log of the start that failed first is still here when it stops trying. The last one is moved aside with
35
+ * `renameRetrying` (a reader may hold the file on Windows, #1223); one that cannot be moved is removed rather than
36
+ * left to read as the new start's. Never fatal
37
+ */
38
+ export function rotateServeLog(log, kept = SERVE_LOGS_KEPT, rename = renameRetrying) {
39
+ // No log to move aside, no room to make: a second call for the same start (the task's line removed before a WMI start)
40
+ if (!existsSync(log)) return
41
+ for (let i = kept - 1; i >= 1; i--) {
42
+ try {
43
+ renameSync(`${log}.${i}`, `${log}.${i + 1}`)
44
+ } catch {
45
+ // there was none, or a reader holds it: the older log stays where it is
46
+ }
47
+ }
48
+ try {
49
+ rename(log, `${log}.1`)
50
+ } catch (e) {
51
+ if (e?.code === 'ENOENT') return
52
+ try {
53
+ rmSync(log, { force: true, maxRetries: 5 })
54
+ } catch {
55
+ // nothing more to do
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * The person's stop, kept in the data folder (#1143): `centralu serve --stop` writes it (`by: 'stop'`), and so does a
62
+ * host that Centralu's own app stops for good when the person quits it there (`by: 'quit'`, the host's
63
+ * lifecycle/stopped-mark.ts, which names the same file). Every host that starts in the folder removes it.
64
+ * `--connection` reports it while no host runs, and a hub then starts none by itself. A hub's own stop (an update)
65
+ * leaves none: it says so with `CENTRALU_STOP_BY=hub`
66
+ */
67
+ export const SERVE_STOPPED_FILE = 'stopped.json'
68
+
69
+ /** Writes the mark; never fatal, a stop that could not note itself still stopped */
70
+ export function markStopped(dataDir, by, now = Date.now()) {
71
+ try {
72
+ replaceFile(join(dataDir, SERVE_STOPPED_FILE), `${JSON.stringify({ v: 1, by, at: now })}\n`)
73
+ } catch {
74
+ // the folder is read-only or gone: nothing to remember it in
75
+ }
76
+ }
77
+
78
+ /** The mark as `{ by, at }`, or null when there is none. One that does not parse is still a stop, by `stop`, at no time */
79
+ export function readStopped(dataDir) {
80
+ let text
81
+ try {
82
+ text = readFileSync(join(dataDir, SERVE_STOPPED_FILE), 'utf8')
83
+ } catch {
84
+ return null
85
+ }
86
+ let o = null
87
+ try {
88
+ o = JSON.parse(text)
89
+ } catch {
90
+ // half written, or written by hand
91
+ }
92
+ return { by: o?.by === 'quit' ? 'quit' : 'stop', at: typeof o?.at === 'number' && Number.isFinite(o.at) ? o.at : null }
93
+ }
94
+
95
+ /** The data folder: the host's own rule (`dataRoot()` in agent-host), CC_DATA_DIR or ~/.centralu */
96
+ export function serveDataDir(env, home) {
97
+ return env.CC_DATA_DIR || join(home, '.centralu')
98
+ }
99
+
100
+ /**
101
+ * The state, or null when there is none yet or it cannot be read as one. A file that is refused (Windows, while a
102
+ * writer or a scanner has it) or does not parse is read again a few times before it counts as unreadable: a
103
+ * `--detach` waiting for the port reads it while the launcher replaces it (#1041).
104
+ */
105
+ export function readServeState(dataDir, { tries = 5, sleep = sleepSync } = {}) {
106
+ const file = join(dataDir, SERVE_STATE_FILE)
107
+ for (let i = 1; ; i++) {
108
+ let raw
109
+ try {
110
+ raw = readFileSync(file, 'utf8')
111
+ } catch (e) {
112
+ if (e?.code === 'ENOENT' || i >= tries) return null
113
+ sleep(10 * i)
114
+ continue
115
+ }
116
+ const state = parseServeState(raw)
117
+ if (state || i >= tries) return state
118
+ sleep(10 * i)
119
+ }
120
+ }
121
+
122
+ function parseServeState(raw) {
123
+ try {
124
+ const v = JSON.parse(raw)
125
+ if (typeof v?.token !== 'string' || v.token.length < 32) return null
126
+ return { token: v.token, port: Number.isInteger(v.port) && v.port >= 1 && v.port <= 65535 ? v.port : null }
127
+ } catch {
128
+ return null
129
+ }
130
+ }
131
+
132
+ /**
133
+ * The state, created on first use. Never replaces a token that is already there.
134
+ *
135
+ * Created with `wx`, so two first runs at the same moment (a `serve` and a `--connection`) cannot
136
+ * each write a different token: the second one fails to create and reads the first one's. A file
137
+ * that exists but cannot be read as state is refused rather than overwritten: it may be a token
138
+ * the client already holds, and a silent new one would lock that client out with "bad token".
139
+ */
140
+ export function ensureServeState(dataDir, platform = process.platform) {
141
+ mkdirSync(dataDir, { recursive: true })
142
+ const file = join(dataDir, SERVE_STATE_FILE)
143
+ if (!existsSync(file)) {
144
+ const token = randomBytes(32).toString('base64url')
145
+ try {
146
+ writePrivate(file, { v: 1, token, port: null }, 'wx')
147
+ } catch (e) {
148
+ if (e?.code !== 'EEXIST') throw e
149
+ }
150
+ }
151
+ const state = readServeState(dataDir)
152
+ if (!state) throw new Error(`${file} exists but is not a serve state file. Move it aside and run again.`)
153
+ keepPrivate(file, platform)
154
+ return state
155
+ }
156
+
157
+ /** Writes the port `serve` listened on, keeping the token. Atomic: a temp file renamed over it */
158
+ export function recordServePort(dataDir, port, platform = process.platform) {
159
+ const file = join(dataDir, SERVE_STATE_FILE)
160
+ const state = readServeState(dataDir)
161
+ if (!state) throw new Error(`${file} is missing or unreadable`)
162
+ if (state.port === port) return
163
+ replacePrivate(file, { v: 1, token: state.token, port }, platform)
164
+ }
165
+
166
+ /**
167
+ * A new token, keeping the port. Only when the person asks (`--rotate-token`): every client that
168
+ * stored the old one has to fetch the new one with `--connection`, and a serve already running
169
+ * keeps answering to the old one until it restarts.
170
+ */
171
+ export function rotateServeToken(dataDir, platform = process.platform) {
172
+ const before = ensureServeState(dataDir, platform)
173
+ replacePrivate(join(dataDir, SERVE_STATE_FILE), { v: 1, token: randomBytes(32).toString('base64url'), port: before.port }, platform)
174
+ return readServeState(dataDir)
175
+ }
176
+
177
+ /**
178
+ * Created 0600 from the first byte, not chmod-ed after: a file written with the default mode and
179
+ * narrowed afterwards is readable by everyone on the machine for the moment in between.
180
+ */
181
+ function writePrivate(file, value, flag) {
182
+ const fd = openSync(file, flag, 0o600)
183
+ try {
184
+ writeSync(fd, `${JSON.stringify(value)}\n`)
185
+ } finally {
186
+ closeSync(fd)
187
+ }
188
+ }
189
+
190
+ /** Replaces a private file whole (`replaceFile`), created 0600 as `writePrivate` creates one */
191
+ function replacePrivate(file, value, platform) {
192
+ replaceFile(file, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: 'wx' })
193
+ keepPrivate(file, platform)
194
+ }
195
+
196
+ /**
197
+ * 0600 again on every use. A copy, a restore from backup or an editor that rewrites the file can
198
+ * leave it readable by the group; the token in it is the key to every RPC. Windows has no mode
199
+ * bits to set (the profile folder's ACL is what protects it there).
200
+ */
201
+ function keepPrivate(file, platform) {
202
+ if (platform === 'win32') return
203
+ if ((statSync(file).mode & 0o777) !== 0o600) chmodSync(file, 0o600)
204
+ }
205
+
206
+ /** serve.pid: `{ pid, hostPid, port }` of the launcher that runs, or null */
207
+ export function readServePid(dataDir) {
208
+ try {
209
+ const v = JSON.parse(readFileSync(join(dataDir, SERVE_PID_FILE), 'utf8'))
210
+ return Number.isInteger(v?.pid) && v.pid > 0 ? { pid: v.pid, hostPid: Number.isInteger(v.hostPid) ? v.hostPid : null, port: v.port ?? null } : null
211
+ } catch {
212
+ return null
213
+ }
214
+ }
215
+
216
+ export function removeServePid(dataDir, pid) {
217
+ if (readServePid(dataDir)?.pid !== pid) return
218
+ try {
219
+ rmSync(join(dataDir, SERVE_PID_FILE), { force: true })
220
+ } catch {
221
+ // another serve's by now, or gone
222
+ }
223
+ }
224
+
225
+ /**
226
+ * A probe that did not prove the token, while this data folder's host is alive (`host.lock`): a host from before #838
227
+ * still running after an update. It is running, and is not sent the token; a hub asking it for a proof is refused
228
+ * until it restarts. Without a live lock holder, what answered is not ours.
229
+ */
230
+ export function heldHere(probe, dataDir, startOf = processStartTime) {
231
+ if (probe.running) return true
232
+ if (!probe.unproven) return false
233
+ return lockHolderIsAlive(dataDir, startOf)
234
+ }
235
+
236
+ /**
237
+ * Whether the process `host.lock` names is the one that wrote it (#838 review): alive, and started when the lock says
238
+ * (`ps -o lstart`, as the host writes it, `instance-lock.ts`). A lock left by a host that died, whose pid another
239
+ * process of the same user now has, names a stranger: counted as running, `--stop` would signal it. A lock without a
240
+ * start time (a host from before #184) or a system without `ps` (Windows) cannot be told apart, so it does not count.
241
+ */
242
+ export function lockHolderIsAlive(dataDir, startOf = processStartTime) {
243
+ let holder
244
+ try {
245
+ holder = parseLockHolder(readFileSync(join(dataDir, 'host.lock'), 'utf8'))
246
+ } catch {
247
+ return false
248
+ }
249
+ if (!holder || !holder.started || !isAlive(holder.pid)) return false
250
+ return startOf(holder.pid) === holder.started
251
+ }
252
+
253
+ /**
254
+ * host.lock's `{ pid, started, by? }`; a bare pid (an older host) has no start time. `by` (#1223, additive) is who
255
+ * started the host: `app`, `keeper` or `serve` (`instance-lock.ts`), absent when the host did not say
256
+ */
257
+ function parseLockHolder(text) {
258
+ const t = text.trim()
259
+ if (/^\d+$/.test(t)) return Number(t) > 0 ? { pid: Number(t), started: null } : null
260
+ try {
261
+ const v = JSON.parse(t)
262
+ if (!Number.isInteger(v?.pid) || v.pid <= 0) return null
263
+ const by = v.by === 'app' || v.by === 'keeper' || v.by === 'serve' ? v.by : null
264
+ return { pid: v.pid, started: typeof v.started === 'string' ? v.started : null, ...(by ? { by } : {}) }
265
+ } catch {
266
+ return null
267
+ }
268
+ }
269
+
270
+ /**
271
+ * A pid's start time, as `instance-lock.ts`'s `processStartTime` reads it: `ps -o lstart=` with the C locale, so the
272
+ * host that wrote the lock and this launcher read the same text. null without `ps` (Windows) or when it fails.
273
+ */
274
+ export function processStartTime(pid) {
275
+ if (process.platform === 'win32') return null
276
+ try {
277
+ return execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000, env: { ...process.env, LC_ALL: 'C' }, stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null
278
+ } catch {
279
+ return null
280
+ }
281
+ }
282
+
283
+ /** Who `host.lock` names as the data folder's owner, `{ pid, started, by? }`, or null */
284
+ export function lockHolder(dataDir) {
285
+ try {
286
+ return parseLockHolder(readFileSync(join(dataDir, 'host.lock'), 'utf8'))
287
+ } catch {
288
+ return null
289
+ }
290
+ }
291
+
292
+ /** Who `host.lock` names as the data folder's owner, or null */
293
+ export function lockHolderPid(dataDir) {
294
+ try {
295
+ return parseLockHolder(readFileSync(join(dataDir, 'host.lock'), 'utf8'))?.pid ?? null
296
+ } catch {
297
+ return null
298
+ }
299
+ }