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.
- package/bin/centralu.mjs +466 -117
- package/bin/node-floor.mjs +42 -0
- package/bin/platform.mjs +380 -0
- package/bin/semver.mjs +37 -4
- package/bin/serve-args.mjs +116 -0
- package/bin/serve-autostart.mjs +489 -0
- package/bin/serve-detach.mjs +289 -0
- package/bin/serve-files.mjs +299 -0
- package/bin/serve-handover.mjs +152 -0
- package/bin/serve-host.mjs +395 -0
- package/bin/serve-install.mjs +204 -0
- package/bin/serve-port.mjs +79 -0
- package/bin/serve-run.mjs +197 -0
- package/bin/serve-util.mjs +127 -0
- package/bin/serve-window.mjs +112 -0
- package/bin/serve-windows.mjs +171 -0
- package/bin/serve.mjs +37 -0
- package/bin/swap.mjs +48 -0
- package/package.json +10 -6
|
@@ -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
|
+
}
|