centralu 0.1.0-beta.26 → 0.1.0-beta.28

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,277 @@
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, renameSync, 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, waitUntil } from './serve-util.mjs'
10
+ import { SERVE_LOG_FILE, serveDataDir, readServeState, ensureServeState, heldHere, lockHolderPid, readServePid } from './serve-files.mjs'
11
+ import { askPowershell, detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, windowsPowershell, windowsTool, wslDistro, wslUser } from './serve-windows.mjs'
12
+ import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
13
+ import { CHILD_FLAG } from './serve-args.mjs'
14
+ import { protocolVersionOf, connectionLine, hostProvesOf, probeHost, askHostToStop } from './serve-host.mjs'
15
+ import { describe, runServe, serveFolder } from './serve-run.mjs'
16
+
17
+ /**
18
+ * What a detached launcher needs from the `--detach` that started it. A process created through WMI
19
+ * does not inherit this environment: it gets the account's default one. Everything else it needs
20
+ * (the token, the port) is in the data folder. Never a secret.
21
+ */
22
+ export const CARRIED_ENV = ['CC_DATA_DIR', 'CENTRALU_HOST_ENTRY', 'CENTRALU_MANAGED']
23
+
24
+ export function encodeChildSpec({ env, log }) {
25
+ const carried = {}
26
+ for (const k of CARRIED_ENV) if (typeof env[k] === 'string' && env[k]) carried[k] = env[k]
27
+ return Buffer.from(JSON.stringify({ log, env: carried }), 'utf8').toString('base64url')
28
+ }
29
+
30
+ /** `{ log, env }` from `--detached-child=`, only the carried variables; null when it is not one */
31
+ export function decodeChildSpec(raw) {
32
+ try {
33
+ const v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'))
34
+ if (typeof v?.log !== 'string' || !v.log) return null
35
+ const env = {}
36
+ for (const k of CARRIED_ENV) if (typeof v.env?.[k] === 'string') env[k] = v.env[k]
37
+ return { log: v.log, env }
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Starts the launcher outside this session (`detachHow`). `{ ok: true, pid, exited?, alive? }` once
45
+ * something was created, `{ ok: false, blocked, message }` when nothing was: `blocked` is the WMI
46
+ * refusal the hub falls back from.
47
+ */
48
+ export async function startDetached({ how, execPath, args, env, cwd }) {
49
+ if (how === 'setsid') {
50
+ return await new Promise((resolve) => {
51
+ let child
52
+ try {
53
+ child = spawn(execPath, args, { detached: true, stdio: 'ignore', cwd, env, windowsHide: true })
54
+ } catch (e) {
55
+ return resolve({ ok: false, blocked: false, message: `could not start ${execPath}: ${e.message}` })
56
+ }
57
+ child.once('error', (e) => resolve({ ok: false, blocked: false, message: `could not start ${execPath}: ${e.message}` }))
58
+ child.once('spawn', () => {
59
+ const exited = new Promise((r) => child.once('exit', (code, signal) => r(signal ?? code)))
60
+ child.unref()
61
+ resolve({ ok: true, pid: child.pid, exited })
62
+ })
63
+ })
64
+ }
65
+ const ps = windowsPowershell(how, env)
66
+ if (!ps) return { ok: false, blocked: true, message: wmiBlockedMessage('Windows PowerShell is not reachable from this WSL distro') }
67
+ let commandLine
68
+ try {
69
+ if (how === 'wmi') commandLine = [execPath, ...args].map(windowsArg).join(' ')
70
+ else {
71
+ const distro = wslDistro(env.WSL_DISTRO_NAME ?? '')
72
+ const user = wslUser(userInfo().username)
73
+ commandLine = ['wsl.exe', '-d', distro, '-u', user, '--cd', cwd, '--exec', execPath, ...args].map(windowsArg).join(' ')
74
+ }
75
+ } catch (e) {
76
+ return { ok: false, blocked: false, message: e.message }
77
+ }
78
+ const script = wmiScript({ commandLine, cwd: how === 'wmi' ? cwd : null })
79
+ const answer = await askPowershell(ps, script, env, parseWmiAnswer)
80
+ if (!answer.ok) return { ok: false, blocked: true, message: wmiBlockedMessage(answer.message) }
81
+ // A Windows pid can be watched from Windows; from inside WSL it names nothing here
82
+ /*
83
+ * Measured on a Windows 11 laptop (2026-10-08): a `wsl.exe` that WMI creates can hang without
84
+ * running anything in the distro (it starts a second wsl.exe and waits), while `wsl.exe --list`
85
+ * from the same context answers. `--detach` reports that as a start that never ran, and ends what
86
+ * it created rather than leave it waiting: this pid and its children, nothing else.
87
+ */
88
+ const taskkill = windowsTool(how, env, ['System32', 'taskkill.exe'])
89
+ const stop = taskkill ? () => runCaptured(taskkill, ['/PID', String(answer.pid), '/T', '/F'], env) : null
90
+ return { ok: true, pid: answer.pid, alive: how === 'wmi' ? () => isAlive(answer.pid) : null, stop }
91
+ }
92
+
93
+ /**
94
+ * `centralu serve --detach` (docs/plans/remote-hub.md §10.4, S9): starts `serve` outside this
95
+ * session (`detachHow`) and returns once its host answers a hello, so the caller, typically the
96
+ * hub over ssh, can connect at once.
97
+ *
98
+ * stdout carries one JSON line, read by the hub (`links/tunnel.ts`): the connection line with
99
+ * `hostRunning: true` and `detach: { ok: true, how, already }`, or `{ v: 1, detach: { ok: false,
100
+ * how, reason, message } }`. `reason` is `wmi_blocked` when WMI created nothing or created something
101
+ * that never ran, which the hub answers with a link-bound start; `exited` and `timeout` quote the
102
+ * end of serve.log. Exit codes do not survive Windows' ssh (agent-host.md §4.8), so the line is
103
+ * what counts; the exit code is 0 or 1 for a person at a prompt.
104
+ *
105
+ * `start` and the two waits are parameters so tests can stand in for WMI.
106
+ */
107
+ export async function runDetach({
108
+ env,
109
+ home,
110
+ entry,
111
+ version,
112
+ cliPath,
113
+ port = null,
114
+ platform = process.platform,
115
+ execPath = process.execPath,
116
+ cwd = process.cwd(),
117
+ start = startDetached,
118
+ how = detachHow(platform, env),
119
+ timeoutMs = 30_000,
120
+ noStartMs = 10_000,
121
+ write = (line) => process.stdout.write(line),
122
+ }) {
123
+ // Created here, so the token exists before the launcher reads it and `answered` reads it again
124
+ const { dataDir } = serveFolder({ env, home, cliPath, platform })
125
+ const fail = (reason, message) => answerLine(write, 'detach', { ok: false, how, reason, message })
126
+ const answered = async (already) => {
127
+ const d = await describe({ env, dataDir, state: ensureServeState(dataDir, platform), entry, version })
128
+ if (!d?.hostRunning) return false
129
+ write(`${connectionLine({ ...d, detach: { ok: true, how, already } })}\n`)
130
+ return true
131
+ }
132
+ if (await answered(true)) return 0
133
+
134
+ const log = join(dataDir, SERVE_LOG_FILE)
135
+ // One start per log: the last one's is kept beside it, and its absence is how a start that never ran shows
136
+ try {
137
+ renameSync(log, `${log}.1`)
138
+ } catch {
139
+ // there was none
140
+ }
141
+ const args = [cliPath, 'serve', ...(port !== null ? ['--port', String(port)] : []), `${CHILD_FLAG}${encodeChildSpec({ env, log })}`]
142
+ let started
143
+ try {
144
+ started = await start({ how, execPath, args, env, cwd })
145
+ } catch (e) {
146
+ return fail('failed', `could not start centralu serve in the background: ${e.message}`)
147
+ }
148
+ if (!started.ok) return fail(started.blocked ? 'wmi_blocked' : 'failed', started.message)
149
+ say(`started centralu serve in the background (${how}, pid ${started.pid ?? 'unknown'}); waiting for its host`)
150
+
151
+ let exited
152
+ started.exited?.then((c) => (exited = c ?? 'unknown'))
153
+ const t0 = Date.now()
154
+ while (Date.now() - t0 < timeoutMs) {
155
+ if (await answered(false)) return 0
156
+ const why = tail(log)
157
+ if (exited !== undefined) return fail('exited', `centralu serve exited (${exited}) before its host answered${why ? `:\n${why}` : ''}`)
158
+ const gone = started.alive?.() === false
159
+ if (how !== 'setsid' && !existsSync(log) && (gone || Date.now() - t0 > noStartMs)) {
160
+ // WMI said yes, and nothing ran: the process was stopped before Node started, or hangs (`startDetached`)
161
+ if (!gone) await started.stop?.()
162
+ return fail('wmi_blocked', wmiBlockedMessage(gone ? `process ${started.pid} ended before it started` : `nothing started within ${Math.round(noStartMs / 1000)} s`))
163
+ }
164
+ if (gone) return fail('exited', `centralu serve exited before its host answered${why ? `:\n${why}` : ''}`)
165
+ await sleep(250)
166
+ }
167
+ const why = tail(log)
168
+ return fail('timeout', `the host did not answer within ${Math.round(timeoutMs / 1000)} s${why ? `:\n${why}` : ''}`)
169
+ }
170
+
171
+ /**
172
+ * The launcher `--detach` started: `runServe` writing to serve.log. The carried variables are
173
+ * already in `env` (centralu.mjs merges them before it looks for the host).
174
+ */
175
+ export async function runDetachedChild({ spec, ...opts }) {
176
+ let fd = null
177
+ try {
178
+ fd = openSync(spec.log, 'a')
179
+ sayTo((text) => {
180
+ try {
181
+ writeSync(fd, text)
182
+ } catch {
183
+ // nowhere left to say it
184
+ }
185
+ })
186
+ } catch {
187
+ // no log: say() keeps stderr, which nobody reads; --detach then reports the start as never run
188
+ }
189
+ say(`started in the background (pid ${process.pid}, Node ${process.version})`)
190
+ try {
191
+ return await runServe({ ...opts, logFd: fd })
192
+ } catch (e) {
193
+ say(e?.message ?? String(e))
194
+ return 1
195
+ }
196
+ }
197
+
198
+ /**
199
+ * `centralu serve --stop` (docs/plans/remote-hub.md §10.4, S10): asks the running host to stop over
200
+ * its own socket and waits until it has, so its shutdown runs (its agents, terminals and app
201
+ * processes stopped in order, the store closed). Measured on Windows: killing the WMI-started
202
+ * launcher ended the host 143 ms later with no "shutting down" line, because the host sits in
203
+ * libuv's kill-on-close job object; so on Windows killing is the last resort, not the way.
204
+ *
205
+ * Then, only for a host that did not go: posix SIGTERM to the launcher (which passes it to the host,
206
+ * the same clean stop), and on Windows ending the host. A host from before `host.stop` gets that
207
+ * too. The launcher ends with its host; one still there afterwards is ended.
208
+ *
209
+ * Only the processes serve.pid and host.lock name, and only while they agree with each other and
210
+ * the host answered with this folder's token: a stale pid is never signalled.
211
+ *
212
+ * stdout: one line, `{ v: 1, stop: { ok, wasRunning, how?, message? } }`.
213
+ */
214
+ export async function runStop({
215
+ env,
216
+ home,
217
+ entry,
218
+ platform = process.platform,
219
+ timeoutMs = 30_000,
220
+ graceMs = 15_000,
221
+ kill = (pid, sig) => process.kill(pid, sig),
222
+ write = (line) => process.stdout.write(line),
223
+ }) {
224
+ const dataDir = serveDataDir(env, home)
225
+ const report = (stop) => answerLine(write, 'stop', stop)
226
+ const state = readServeState(dataDir)
227
+ if (!state) return report({ ok: true, wasRunning: false, message: `no centralu serve has run for ${dataDir}` })
228
+ const port = state.port ?? DEFAULT_SERVE_PORT
229
+ const installed = protocolVersionOf(entry) ?? 0
230
+ const prove = hostProvesOf(entry)
231
+ const probe = () => probeHost({ port, token: state.token, protocolVersion: installed, prove })
232
+ const first = await probe()
233
+ if (!heldHere(first, dataDir)) return report({ ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}` })
234
+
235
+ const hostPid = lockHolderPid(dataDir)
236
+ const launcher = readServePid(dataDir)
237
+ // serve.pid is only believed when it names the host that holds the folder now
238
+ const launcherPid = launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
239
+ const down = async () => !(await probe()).running && !(hostPid && isAlive(hostPid))
240
+
241
+ let asked = await askHostToStop({ port, token: state.token, protocolVersion: first.protocolVersion ?? installed, prove })
242
+ if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, token: state.token, protocolVersion: asked.protocolVersion, prove })
243
+ let how = 'asked'
244
+ let stopped = asked.ok && (await waitUntil(down, timeoutMs))
245
+ let note = asked.ok ? null : `the host did not take the request to stop (${asked.message})`
246
+ if (!stopped) {
247
+ const target = platform !== 'win32' && launcherPid ? launcherPid : hostPid
248
+ if (target) {
249
+ how = platform === 'win32' ? 'ended' : 'signal'
250
+ try {
251
+ kill(target, 'SIGTERM')
252
+ } catch {
253
+ // gone in the meantime
254
+ }
255
+ stopped = await waitUntil(down, platform === 'win32' ? 5_000 : graceMs)
256
+ if (!stopped && hostPid && isAlive(hostPid)) {
257
+ how = 'ended'
258
+ try {
259
+ kill(hostPid, 'SIGKILL')
260
+ } catch {
261
+ // gone in the meantime
262
+ }
263
+ stopped = await waitUntil(down, 5_000)
264
+ }
265
+ }
266
+ if (how === 'ended') note = `${note ? `${note}; ` : ''}ended the host without its shutdown`
267
+ }
268
+ if (launcherPid && launcherPid !== process.pid && !(await waitUntil(() => !isAlive(launcherPid), 5_000))) {
269
+ try {
270
+ kill(launcherPid, 'SIGKILL')
271
+ } catch {
272
+ // gone in the meantime
273
+ }
274
+ }
275
+ if (!stopped) return report({ ok: false, wasRunning: true, how, message: `the host on 127.0.0.1:${port} is still running${note ? ` (${note})` : ''}` })
276
+ return report({ ok: true, wasRunning: true, how, ...(note ? { message: note } : {}) })
277
+ }
@@ -0,0 +1,218 @@
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, rmSync, statSync, writeSync, closeSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { isAlive, 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
+ /** The data folder: the host's own rule (`dataRoot()` in agent-host), CC_DATA_DIR or ~/.centralu */
29
+ export function serveDataDir(env, home) {
30
+ return env.CC_DATA_DIR || join(home, '.centralu')
31
+ }
32
+
33
+ /**
34
+ * The state, or null when there is none yet or it cannot be read as one. A file that is refused (Windows, while a
35
+ * writer or a scanner has it) or does not parse is read again a few times before it counts as unreadable: a
36
+ * `--detach` waiting for the port reads it while the launcher replaces it (#1041).
37
+ */
38
+ export function readServeState(dataDir, { tries = 5, sleep = sleepSync } = {}) {
39
+ const file = join(dataDir, SERVE_STATE_FILE)
40
+ for (let i = 1; ; i++) {
41
+ let raw
42
+ try {
43
+ raw = readFileSync(file, 'utf8')
44
+ } catch (e) {
45
+ if (e?.code === 'ENOENT' || i >= tries) return null
46
+ sleep(10 * i)
47
+ continue
48
+ }
49
+ const state = parseServeState(raw)
50
+ if (state || i >= tries) return state
51
+ sleep(10 * i)
52
+ }
53
+ }
54
+
55
+ function parseServeState(raw) {
56
+ try {
57
+ const v = JSON.parse(raw)
58
+ if (typeof v?.token !== 'string' || v.token.length < 32) return null
59
+ return { token: v.token, port: Number.isInteger(v.port) && v.port >= 1 && v.port <= 65535 ? v.port : null }
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ /**
66
+ * The state, created on first use. Never replaces a token that is already there.
67
+ *
68
+ * Created with `wx`, so two first runs at the same moment (a `serve` and a `--connection`) cannot
69
+ * each write a different token: the second one fails to create and reads the first one's. A file
70
+ * that exists but cannot be read as state is refused rather than overwritten: it may be a token
71
+ * the client already holds, and a silent new one would lock that client out with "bad token".
72
+ */
73
+ export function ensureServeState(dataDir, platform = process.platform) {
74
+ mkdirSync(dataDir, { recursive: true })
75
+ const file = join(dataDir, SERVE_STATE_FILE)
76
+ if (!existsSync(file)) {
77
+ const token = randomBytes(32).toString('base64url')
78
+ try {
79
+ writePrivate(file, { v: 1, token, port: null }, 'wx')
80
+ } catch (e) {
81
+ if (e?.code !== 'EEXIST') throw e
82
+ }
83
+ }
84
+ const state = readServeState(dataDir)
85
+ if (!state) throw new Error(`${file} exists but is not a serve state file. Move it aside and run again.`)
86
+ keepPrivate(file, platform)
87
+ return state
88
+ }
89
+
90
+ /** Writes the port `serve` listened on, keeping the token. Atomic: a temp file renamed over it */
91
+ export function recordServePort(dataDir, port, platform = process.platform) {
92
+ const file = join(dataDir, SERVE_STATE_FILE)
93
+ const state = readServeState(dataDir)
94
+ if (!state) throw new Error(`${file} is missing or unreadable`)
95
+ if (state.port === port) return
96
+ replacePrivate(file, { v: 1, token: state.token, port }, platform)
97
+ }
98
+
99
+ /**
100
+ * A new token, keeping the port. Only when the person asks (`--rotate-token`): every client that
101
+ * stored the old one has to fetch the new one with `--connection`, and a serve already running
102
+ * keeps answering to the old one until it restarts.
103
+ */
104
+ export function rotateServeToken(dataDir, platform = process.platform) {
105
+ const before = ensureServeState(dataDir, platform)
106
+ replacePrivate(join(dataDir, SERVE_STATE_FILE), { v: 1, token: randomBytes(32).toString('base64url'), port: before.port }, platform)
107
+ return readServeState(dataDir)
108
+ }
109
+
110
+ /**
111
+ * Created 0600 from the first byte, not chmod-ed after: a file written with the default mode and
112
+ * narrowed afterwards is readable by everyone on the machine for the moment in between.
113
+ */
114
+ function writePrivate(file, value, flag) {
115
+ const fd = openSync(file, flag, 0o600)
116
+ try {
117
+ writeSync(fd, `${JSON.stringify(value)}\n`)
118
+ } finally {
119
+ closeSync(fd)
120
+ }
121
+ }
122
+
123
+ /** Replaces a private file whole (`replaceFile`), created 0600 as `writePrivate` creates one */
124
+ function replacePrivate(file, value, platform) {
125
+ replaceFile(file, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: 'wx' })
126
+ keepPrivate(file, platform)
127
+ }
128
+
129
+ /**
130
+ * 0600 again on every use. A copy, a restore from backup or an editor that rewrites the file can
131
+ * leave it readable by the group; the token in it is the key to every RPC. Windows has no mode
132
+ * bits to set (the profile folder's ACL is what protects it there).
133
+ */
134
+ function keepPrivate(file, platform) {
135
+ if (platform === 'win32') return
136
+ if ((statSync(file).mode & 0o777) !== 0o600) chmodSync(file, 0o600)
137
+ }
138
+
139
+ /** serve.pid: `{ pid, hostPid, port }` of the launcher that runs, or null */
140
+ export function readServePid(dataDir) {
141
+ try {
142
+ const v = JSON.parse(readFileSync(join(dataDir, SERVE_PID_FILE), 'utf8'))
143
+ return Number.isInteger(v?.pid) && v.pid > 0 ? { pid: v.pid, hostPid: Number.isInteger(v.hostPid) ? v.hostPid : null, port: v.port ?? null } : null
144
+ } catch {
145
+ return null
146
+ }
147
+ }
148
+
149
+ export function removeServePid(dataDir, pid) {
150
+ if (readServePid(dataDir)?.pid !== pid) return
151
+ try {
152
+ rmSync(join(dataDir, SERVE_PID_FILE), { force: true })
153
+ } catch {
154
+ // another serve's by now, or gone
155
+ }
156
+ }
157
+
158
+ /**
159
+ * A probe that did not prove the token, while this data folder's host is alive (`host.lock`): a host from before #838
160
+ * still running after an update. It is running, and is not sent the token; a hub asking it for a proof is refused
161
+ * until it restarts. Without a live lock holder, what answered is not ours.
162
+ */
163
+ export function heldHere(probe, dataDir, startOf = processStartTime) {
164
+ if (probe.running) return true
165
+ if (!probe.unproven) return false
166
+ return lockHolderIsAlive(dataDir, startOf)
167
+ }
168
+
169
+ /**
170
+ * Whether the process `host.lock` names is the one that wrote it (#838 review): alive, and started when the lock says
171
+ * (`ps -o lstart`, as the host writes it, `instance-lock.ts`). A lock left by a host that died, whose pid another
172
+ * process of the same user now has, names a stranger: counted as running, `--stop` would signal it. A lock without a
173
+ * start time (a host from before #184) or a system without `ps` (Windows) cannot be told apart, so it does not count.
174
+ */
175
+ export function lockHolderIsAlive(dataDir, startOf = processStartTime) {
176
+ let holder
177
+ try {
178
+ holder = parseLockHolder(readFileSync(join(dataDir, 'host.lock'), 'utf8'))
179
+ } catch {
180
+ return false
181
+ }
182
+ if (!holder || !holder.started || !isAlive(holder.pid)) return false
183
+ return startOf(holder.pid) === holder.started
184
+ }
185
+
186
+ /** host.lock's `{ pid, started }`; a bare pid (an older host) has no start time */
187
+ function parseLockHolder(text) {
188
+ const t = text.trim()
189
+ if (/^\d+$/.test(t)) return Number(t) > 0 ? { pid: Number(t), started: null } : null
190
+ try {
191
+ const v = JSON.parse(t)
192
+ return Number.isInteger(v?.pid) && v.pid > 0 ? { pid: v.pid, started: typeof v.started === 'string' ? v.started : null } : null
193
+ } catch {
194
+ return null
195
+ }
196
+ }
197
+
198
+ /**
199
+ * A pid's start time, as `instance-lock.ts`'s `processStartTime` reads it: `ps -o lstart=` with the C locale, so the
200
+ * host that wrote the lock and this launcher read the same text. null without `ps` (Windows) or when it fails.
201
+ */
202
+ export function processStartTime(pid) {
203
+ if (process.platform === 'win32') return null
204
+ try {
205
+ return execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000, env: { ...process.env, LC_ALL: 'C' }, stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null
206
+ } catch {
207
+ return null
208
+ }
209
+ }
210
+
211
+ /** Who `host.lock` names as the data folder's owner, or null */
212
+ export function lockHolderPid(dataDir) {
213
+ try {
214
+ return parseLockHolder(readFileSync(join(dataDir, 'host.lock'), 'utf8'))?.pid ?? null
215
+ } catch {
216
+ return null
217
+ }
218
+ }