centralu 0.1.0-beta.46 → 0.1.0-beta.47

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 CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  } from './platform.mjs'
46
46
  import {
47
47
  decodeChildSpec,
48
+ gateHostStart,
48
49
  parseServeArgs,
49
50
  printConnection,
50
51
  rotateToken,
@@ -54,6 +55,9 @@ import {
54
55
  runDetachedChild,
55
56
  runServe,
56
57
  runStop,
58
+ runSwitch,
59
+ runSwitchRun,
60
+ runSwitchStatus,
57
61
  runWindow,
58
62
  SERVE_HELP,
59
63
  } from './serve.mjs'
@@ -631,6 +635,19 @@ switch (cmd) {
631
635
  process.exitCode = rotateToken({ env: process.env, home: HOME })
632
636
  break
633
637
  }
638
+ // The hub's switch of the install it manages (serve-switch.mjs): it starts and checks hosts only through serve's CLI
639
+ if (args.mode === 'switch') {
640
+ process.exitCode = await runSwitch({ env: process.env, home: HOME, raw: args.switchArg, cliPath: realpathSync(process.argv[1]) })
641
+ break
642
+ }
643
+ if (args.mode === 'switch-run') {
644
+ process.exitCode = await runSwitchRun({ env: process.env, home: HOME, raw: args.switchArg, dataDir: args.dataDir })
645
+ break
646
+ }
647
+ if (args.mode === 'switch-status') {
648
+ process.exitCode = await runSwitchStatus({ env: process.env, home: HOME, seen: args.seen })
649
+ break
650
+ }
634
651
  /*
635
652
  * Before anything spawns a host: under a Node below the floor the host crashes on its first
636
653
  * database call, with nothing in its log (issue #699). `--detach` also answers in the line the
@@ -640,6 +657,19 @@ switch (cmd) {
640
657
  * machine what Node it has.
641
658
  */
642
659
  const startsHost = args.mode === 'serve' || args.mode === 'detach' || args.mode === 'window' || (args.mode === 'autostart' && args.autostart === 'on')
660
+ /*
661
+ * A switch of the managed install under way there (§10.5a): no host starts between its stop and its start, and one
662
+ * whose switcher died is finished first, before this version refuses for a reason of its own (a host already
663
+ * answering, a missing host entry, a Node below the floor), so a reboot or a sign-in finishes it with no hub (#1907).
664
+ * Not for the launcher `--detach` started: that one asked already
665
+ */
666
+ if ((args.mode === 'serve' || args.mode === 'detach' || args.mode === 'window') && args.child === undefined) {
667
+ const refused = await gateHostStart({ env: process.env, home: HOME, version: pkg.version, detach: args.mode === 'detach' })
668
+ if (refused !== null) {
669
+ process.exitCode = refused
670
+ break
671
+ }
672
+ }
643
673
  if (startsHost && belowFloor(process.versions.node, NODE_FLOOR)) {
644
674
  const message = oldNodeSentence(process.versions.node, NODE_FLOOR)
645
675
  console.error(`centralu serve: ${message}`)
@@ -77,6 +77,22 @@ export const FROM_RE = /^[A-Za-z0-9._-]{1,64}$/
77
77
  * `--autostart` writes starts serve on the port it recorded.
78
78
  */
79
79
  export function parseServeArgs(argv) {
80
+ // The hub's switch of the install it manages (docs/plans/remote-hub.md §10.5a): whole commands of their own, not in the help
81
+ const b64 = (a) => typeof a === 'string' && /^[A-Za-z0-9_-]{1,4096}$/.test(a)
82
+ if (argv[0] === '--switch') {
83
+ if (argv.length !== 2 || !b64(argv[1])) return { error: '--switch takes one argument, the switch the hub wrote' }
84
+ return { mode: 'switch', port: null, switchArg: argv[1] }
85
+ }
86
+ // What `--switch` starts: the hub's argument as it came, and the data folder (base64url) when it is not the default
87
+ if (argv[0] === '--switch-run') {
88
+ if (argv.length < 2 || argv.length > 3 || !argv.slice(1).every(b64)) return { error: '--switch-run takes the switch the hub wrote, and the data folder' }
89
+ return { mode: 'switch-run', port: null, switchArg: argv[1], dataDir: argv[2] ?? null }
90
+ }
91
+ if (argv[0] === '--switch-status') {
92
+ if (argv.length === 1) return { mode: 'switch-status', port: null, seen: null }
93
+ if (argv.length === 3 && argv[1] === '--seen' && /^[0-9A-Za-z-]{8,64}$/.test(argv[2])) return { mode: 'switch-status', port: null, seen: argv[2] }
94
+ return { error: '--switch-status takes only --seen <id>' }
95
+ }
80
96
  let mode = 'serve'
81
97
  let port = null
82
98
  let help = false
@@ -602,12 +602,20 @@ export function foreignHolder(dataDir) {
602
602
  * future (a clock set back since) proves nothing.
603
603
  */
604
604
  export function lockHolderIsAlive(dataDir, startOf = processStartTime, createdAt = processCreatedAt) {
605
- const holder = lockHolder(dataDir)
605
+ return isWriter(lockHolder(dataDir), startOf, createdAt) === true
606
+ }
607
+
608
+ /**
609
+ * Whether `{ pid, started, at }` names the live process that wrote it, by `lockHolderIsAlive`'s rule: true, false, or
610
+ * null when it lives and Windows cannot say when it was created (another logon's process); each caller decides what
611
+ * that counts as
612
+ */
613
+ export function isWriter(holder, startOf = processStartTime, createdAt = processCreatedAt) {
606
614
  if (!holder || !isAlive(holder.pid)) return false
607
615
  if (holder.started) return startOf(holder.pid) === holder.started
608
616
  if (holder.at === undefined || holder.at > Date.now()) return false
609
617
  const created = createdAt(holder.pid)
610
- return created !== null && created <= holder.at + CLOCK_TICK_MS
618
+ return created === null ? null : created <= holder.at + CLOCK_TICK_MS
611
619
  }
612
620
 
613
621
  /** One tick of the Windows clock */
@@ -105,7 +105,7 @@ export function remoteLayout(dataDir) {
105
105
  }
106
106
 
107
107
  /** A word a pointer file may hold: a folder name, nothing a path could escape with */
108
- const POINTER_WORD = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/
108
+ export const POINTER_WORD = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/
109
109
 
110
110
  /** `current` / `previous`: one line, "<centralu version> <node version>". null for anything else */
111
111
  export function parsePointer(text) {
@@ -146,7 +146,21 @@ export function installInfo(dataDir, env, nodeVersion = process.versions.node) {
146
146
  const source = p && installSource(join(l.versions, p.version))
147
147
  return p && source ? { ...p, source } : p
148
148
  }
149
- return { managed: env.CENTRALU_MANAGED === '1', current: read(l.current), previous: read(l.previous), node: nodeVersion }
149
+ return { managed: env.CENTRALU_MANAGED === '1', current: read(l.current), previous: read(l.previous), node: nodeVersion, lastSwitch: lastSwitchOf(l.root) }
150
+ }
151
+
152
+ /**
153
+ * The last switch there (`switch.json`, serve-switch.mjs), as much as a hub needs to know whether to read it: its id,
154
+ * whether it ended, and whether its outcome was reported (`--seen`). A hub that was away for the whole of it, or
155
+ * restarted since, learns from this at its next open that there is an outcome to say (#1907). Null when none ran
156
+ */
157
+ function lastSwitchOf(root) {
158
+ try {
159
+ const s = JSON.parse(readFileSync(join(root, 'switch.json'), 'utf8'))
160
+ return typeof s?.id === 'string' ? { id: s.id, ended: typeof s.outcome === 'string', seen: s.seen === true } : null
161
+ } catch {
162
+ return null
163
+ }
150
164
  }
151
165
 
152
166
  /**
package/bin/serve-run.mjs CHANGED
@@ -143,7 +143,6 @@ async function signingHostKey({ dataDir, state, entry, keyId, hubKey, probeKey }
143
143
  export async function runServe({ env, home, entry, version, cliPath, port: askedPort, from = null, platform = process.platform, execPath = process.execPath, logFd = null, handOver = moveIntoSignIn }) {
144
144
  const { dataDir, state } = serveFolder({ env, home, cliPath, platform })
145
145
  const protocolVersion = protocolVersionOf(entry)
146
-
147
146
  // A serve that is already up answers on the port it recorded; say so before starting a host that
148
147
  // would only fail on the data folder's lock with a less specific message.
149
148
  const recorded = state.port ?? DEFAULT_SERVE_PORT
@@ -0,0 +1,636 @@
1
+ /**
2
+ * `centralu serve --switch`: the machine runs a version switch of the install the hub manages (docs/plans/remote-hub.md
3
+ * §10.5a, #1903). The hub places the new version beside the running one, then hands this one command over ssh, and from
4
+ * then on only reads the outcome (`--switch-status`). Whatever happens to the hub or the link after the handover, the
5
+ * switcher, detached here the way `--detach` detaches a host, stops the old host, switches the pointers, starts and
6
+ * checks the new version, and puts the old one back when it does not answer.
7
+ *
8
+ * The switcher is the launcher of the newer of the two versions. It drives the other one only through the CLI every
9
+ * managed version has (`serve --stop`, `serve --detach`, `serve --connection`), run on its own Node from its folder under
10
+ * `<data>/remote/versions/`, as the managed launcher runs it: nothing is imported across versions.
11
+ *
12
+ * What it does is in `<data>/remote/switch.json`, each step written before it is taken, and `switch.lock` names the live
13
+ * process at work. A switcher that died is finished by the next reader (`reconcile`): `--switch-status`, any `serve` or
14
+ * `--detach` there, or the next `--switch`.
15
+ */
16
+ import { spawn } from 'node:child_process'
17
+ import { closeSync, cpSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'node:fs'
18
+ import { randomBytes } from 'node:crypto'
19
+ import { join } from 'node:path'
20
+ import { answerLine, replaceFile, runCaptured, say, sleep, tail, waitUntil } from './serve-util.mjs'
21
+ import { isWriter, processStartTime, serveDataDir } from './serve-files.mjs'
22
+ import { POINTER_WORD, managedLauncherScript, parsePointer, remoteLayout } from './serve-install.mjs'
23
+ import { detachHow } from './serve-windows.mjs'
24
+
25
+ export const SWITCH_FILE = 'switch.json'
26
+ export const SWITCH_LOCK = 'switch.lock'
27
+ /**
28
+ * The version whose launcher ran the last switch, as a pointer line, written by the hub's handover command before it runs
29
+ * it: the one that reads its status (`--switch-status`), whatever `current` is by then, and that a prune keeps
30
+ */
31
+ export const SWITCH_RUNNER = 'switch.runner'
32
+
33
+ /** How long the started version has to say hello as itself (plan §10.5 step 4) */
34
+ const CHECK_MS = 30_000
35
+ const NO_ANSWER = `it did not answer within ${CHECK_MS / 1000} s`
36
+ /** How long the handover waits for the detached switcher to write its pid before it runs the switch itself */
37
+ const HANDOVER_MS = 10_000
38
+ /**
39
+ * A live pid whose creation time Windows cannot read (another logon's process) counts as the switcher while its record
40
+ * is younger than this: past the longest switch (a stop of 30 s, a start and check of 60 s, a put-back of 60 s), so a
41
+ * pid reused later is not believed for long
42
+ */
43
+ const UNREADABLE_ALIVE_MS = 10 * 60_000
44
+ /** The steps between a stop and the start that follows it: no other host may start then */
45
+ const QUIET_STEPS = new Set(['stop', 'backup', 'switch', 'restore_store', 'roll_back'])
46
+
47
+ const LOCAL_BUILD = /\.local\.\d+$/
48
+ const pointer = (v) => (v && POINTER_WORD.test(String(v.version)) && POINTER_WORD.test(String(v.node)) ? { version: v.version, node: v.node } : null)
49
+ const line = (p) => `${p.version} ${p.node}\n`
50
+
51
+ /**
52
+ * The switch the hub asked for, from `--switch`'s argument (base64url of the hub's JSON, update.ts `switchCommand`),
53
+ * which `--switch-run` is handed unchanged; null when it is not one
54
+ */
55
+ export function decodeSwitch(raw) {
56
+ try {
57
+ const p = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'))
58
+ const to = pointer(p?.to)
59
+ if (!to || typeof p.id !== 'string' || !/^[0-9A-Za-z-]{8,64}$/.test(p.id)) return null
60
+ const by = typeof p.by === 'string' ? p.by.replace(/[^\w .()@-]/g, '').slice(0, 80) : 'another computer'
61
+ const was = { current: pointer(p.was?.current), previous: pointer(p.was?.previous) }
62
+ return { id: p.id, by, to, keep: pointer(p.keep), was, saveStore: p.saveStore === true }
63
+ } catch {
64
+ return null
65
+ }
66
+ }
67
+
68
+ /** `--switch-run`'s second argument: the data folder, which a process WMI creates does not inherit (base64url) */
69
+ const dataDirArg = (env) => (env.CC_DATA_DIR ? [Buffer.from(env.CC_DATA_DIR, 'utf8').toString('base64url')] : [])
70
+ const withDataDir = (env, arg) => (arg ? { ...env, CC_DATA_DIR: Buffer.from(arg, 'base64url').toString('utf8') } : env)
71
+
72
+ /** A time as `--detach`, `serve` and the hub's refusal say it: HH:MM, local */
73
+ export const clockOf = (at) => (typeof at === 'number' ? new Date(at).toTimeString().slice(0, 5) : 'an unknown time')
74
+
75
+ function readJson(file) {
76
+ try {
77
+ return JSON.parse(readFileSync(file, 'utf8'))
78
+ } catch {
79
+ return null
80
+ }
81
+ }
82
+
83
+ /** `switch.json`, or null when there is none */
84
+ export function readSwitch(dataDir) {
85
+ const s = readJson(join(remoteLayout(dataDir).root, SWITCH_FILE))
86
+ return s && typeof s.id === 'string' ? s : null
87
+ }
88
+
89
+ function readPointerFile(file) {
90
+ try {
91
+ return parsePointer(readFileSync(file, 'utf8'))
92
+ } catch {
93
+ return null
94
+ }
95
+ }
96
+
97
+ /** Whether `switch.lock` names a live switcher (`isWriter`), a Windows pid of another logon counting for a while */
98
+ export function lockIsLive(holder, writer = isWriter) {
99
+ if (!holder || !Number.isInteger(holder.pid)) return false
100
+ const w = writer(holder)
101
+ return w === null ? Date.now() - holder.at < UNREADABLE_ALIVE_MS : w
102
+ }
103
+
104
+ /**
105
+ * Takes `switch.lock` for `id`: an exclusive create naming this process, so of two switchers only one runs. A lock whose
106
+ * process is not alive is stale and is taken over, moved aside first so of two takers only one removes it. Null when a
107
+ * live process holds it. The function it returns releases it, only while it is still this one's
108
+ */
109
+ function takeLock(root, id) {
110
+ const file = join(root, SWITCH_LOCK)
111
+ const own = { id, pid: process.pid, started: processStartTime(process.pid), at: Date.now() }
112
+ for (let i = 0; i < 3; i++) {
113
+ try {
114
+ const fd = openSync(file, 'wx')
115
+ try {
116
+ writeSync(fd, JSON.stringify(own))
117
+ } finally {
118
+ closeSync(fd)
119
+ }
120
+ return () => {
121
+ const now = readJson(file)
122
+ if (now?.pid === own.pid && now.at === own.at) rmSync(file, { force: true })
123
+ }
124
+ } catch (e) {
125
+ if (e?.code !== 'EEXIST') throw e
126
+ }
127
+ const holder = readJson(file)
128
+ if (holder ? lockIsLive(holder) : youngerThan(file, 5_000)) return null
129
+ const aside = `${file}.${process.pid}-${randomBytes(4).toString('hex')}.stale`
130
+ try {
131
+ renameSync(file, aside)
132
+ rmSync(aside, { force: true })
133
+ } catch {
134
+ // moved by another taker first
135
+ }
136
+ }
137
+ return null
138
+ }
139
+
140
+ function youngerThan(file, ms) {
141
+ try {
142
+ return Date.now() - statSync(file).mtimeMs < ms
143
+ } catch {
144
+ return false
145
+ }
146
+ }
147
+
148
+ /** One managed version's Node and `centralu.mjs`, as the managed launcher finds them */
149
+ function versionPaths(l, v, platform) {
150
+ return {
151
+ node: platform === 'win32' ? join(l.node, `v${v.node}`, 'node.exe') : join(l.node, `v${v.node}`, 'bin', 'node'),
152
+ cli: join(l.versions, v.version, 'node_modules', 'centralu', 'bin', 'centralu.mjs'),
153
+ }
154
+ }
155
+
156
+ /** One version's `centralu serve <args>`, on its own Node, as the managed launcher runs it */
157
+ function serveOf(l, v, args, env, platform) {
158
+ const { node, cli } = versionPaths(l, v, platform)
159
+ return runCaptured(node, [cli, 'serve', ...args], { ...env, CENTRALU_MANAGED: '1' })
160
+ }
161
+
162
+ /** The last `{ v: 1, … }` line of `stdout` that `pick` finds something in */
163
+ function lastLine(stdout, pick) {
164
+ for (const l of String(stdout).split(/\r?\n/).reverse()) {
165
+ if (!l.trim().startsWith('{')) continue
166
+ try {
167
+ const o = JSON.parse(l)
168
+ if (o?.v === 1 && pick(o) !== undefined) return pick(o)
169
+ } catch {
170
+ // not a line of serve's
171
+ }
172
+ }
173
+ return undefined
174
+ }
175
+
176
+ /** The end of `host.log`, in one line for a sentence ("" when there is none) */
177
+ export function hostLogTail(dataDir) {
178
+ const text = tail(join(dataDir, 'host.log'), 12)
179
+ .split(/\r?\n/)
180
+ .map((s) => s.trim())
181
+ .filter(Boolean)
182
+ .slice(-4)
183
+ .join(' / ')
184
+ return text.length > 600 ? `…${text.slice(-600)}` : text
185
+ }
186
+
187
+ /**
188
+ * Writes both pointers (`previous` first, then `current`, each by rename) and the managed launcher when it is not there
189
+ * yet (an update over an npm install). A null `current` gives the machine back the Centralu it ran before: the pointers
190
+ * and the launcher go, so the lookup finds that one again
191
+ */
192
+ export function setPointers(dataDir, current, previous, platform = process.platform) {
193
+ const l = remoteLayout(dataDir)
194
+ const launcher = join(l.bin, platform === 'win32' ? 'centralu.cmd' : 'centralu')
195
+ if (!current) {
196
+ for (const f of [l.previous, l.current, launcher]) rmSync(f, { force: true })
197
+ return
198
+ }
199
+ for (const v of [current, previous].filter(Boolean)) {
200
+ if (!existsSync(join(l.versions, v.version, 'install.json')) || !existsSync(join(l.node, `v${v.node}`))) throw new Error(`Centralu ${v.version} on Node ${v.node} is not there`)
201
+ }
202
+ if (!existsSync(launcher)) {
203
+ mkdirSync(l.bin, { recursive: true })
204
+ replaceFile(launcher, managedLauncherScript(platform), { mode: 0o755 })
205
+ }
206
+ if (previous) replaceFile(l.previous, line(previous))
207
+ else rmSync(l.previous, { force: true })
208
+ replaceFile(l.current, line(current))
209
+ }
210
+
211
+ /**
212
+ * Removes every version that is neither `current` nor `previous` nor the switch's runner (which reads its outcome), every
213
+ * Node none of them uses, and the store copies of versions that are gone (plan §10.5 step 6). A folder that cannot go (a Windows program still running from it) stays
214
+ * and is named, never fatal
215
+ */
216
+ export function prune(dataDir) {
217
+ const l = remoteLayout(dataDir)
218
+ const keep = [readPointerFile(l.current), readPointerFile(l.previous), readPointerFile(join(l.root, SWITCH_RUNNER))].filter(Boolean)
219
+ const removed = []
220
+ const left = []
221
+ const sweep = (dir, kept, named = true) => {
222
+ let names = []
223
+ try {
224
+ names = readdirSync(dir)
225
+ } catch {
226
+ return
227
+ }
228
+ for (const name of names) {
229
+ if (kept(name) || (name.startsWith('.') && !name.startsWith('.replaced-'))) continue
230
+ try {
231
+ rmSync(join(dir, name), { recursive: true, force: true })
232
+ if (named && !name.startsWith('.')) removed.push(name)
233
+ } catch {
234
+ if (named) left.push(name)
235
+ }
236
+ }
237
+ }
238
+ sweep(l.versions, (n) => keep.some((p) => p.version === n))
239
+ // What the installer moved aside and could not remove then
240
+ sweep(l.root, (n) => !n.startsWith('.replaced-'))
241
+ sweep(l.node, (n) => keep.some((p) => `v${p.node}` === n))
242
+ sweep(join(l.root, 'store-backup'), (n) => keep.some((p) => p.version === n), false)
243
+ return { removed, left }
244
+ }
245
+
246
+ /** The files of the host's store in the data folder: the database and SQLite's write-ahead log beside it */
247
+ const STORE_FILES = ['store.db', 'store.db-wal', 'store.db-shm']
248
+
249
+ /**
250
+ * A copy of the store taken while no host runs, before a dev build first opens it (#1036): a dev build may migrate the
251
+ * store past what the version before it reads, which would then refuse to start. Kept per `owner`, the version that left
252
+ * the store so (`store-backup/<owner>/`), replaced whole
253
+ */
254
+ export function storeBackup(dataDir, owner, before) {
255
+ const dir = join(remoteLayout(dataDir).root, 'store-backup')
256
+ const partial = join(dir, `.partial-${process.pid}`)
257
+ mkdirSync(dir, { recursive: true })
258
+ rmSync(partial, { recursive: true, force: true })
259
+ try {
260
+ mkdirSync(partial, { recursive: true })
261
+ const files = STORE_FILES.filter((f) => existsSync(join(dataDir, f)))
262
+ for (const f of files) cpSync(join(dataDir, f), join(partial, f))
263
+ writeFileSync(join(partial, 'backup.json'), JSON.stringify({ owner, before, files, at: new Date().toISOString() }, null, 2) + '\n')
264
+ rmSync(join(dir, owner), { recursive: true, force: true })
265
+ renameSync(partial, join(dir, owner))
266
+ } finally {
267
+ rmSync(partial, { recursive: true, force: true })
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Puts back the copy `storeBackup` took for `owner`, no host running; false when there is none. A write-ahead log the
273
+ * copy did not have is removed, so SQLite does not replay the dev build's changes onto it. The copy stays
274
+ */
275
+ export function storeRestore(dataDir, owner) {
276
+ const dir = join(remoteLayout(dataDir).root, 'store-backup', owner)
277
+ const meta = readJson(join(dir, 'backup.json'))
278
+ if (meta?.owner !== owner || !Array.isArray(meta.files)) return false
279
+ for (const f of STORE_FILES) rmSync(join(dataDir, f), { force: true })
280
+ for (const f of meta.files.filter((f) => STORE_FILES.includes(f))) cpSync(join(dir, f), join(dataDir, f))
281
+ return true
282
+ }
283
+
284
+ /**
285
+ * The work of one switch, in a data folder, with the steps written to `switch.json` as they are taken. `state` is the
286
+ * file's content; `finish` writes the outcome
287
+ */
288
+ function switchWork(dataDir, state, { env, platform }) {
289
+ const l = remoteLayout(dataDir)
290
+ const file = join(l.root, SWITCH_FILE)
291
+ /** Answers the switch as written, which `runSwitcher` and `reconcile` answer in turn (a status read prints it) */
292
+ const write = (patch) => {
293
+ Object.assign(state, patch, { at: Date.now() })
294
+ replaceFile(file, `${JSON.stringify(state)}\n`)
295
+ return state
296
+ }
297
+ const step = (s) => write({ step: s })
298
+ const from = state.was.current
299
+ const to = state.to
300
+
301
+ /** `serve --stop` of `v` (for an update: the stopping host lists the turns it ends, #1618) */
302
+ const stopWith = async (v) => {
303
+ const r = await serveOf(l, v, ['--stop'], { ...env, CENTRALU_STOP_BY: 'hub', CENTRALU_STOP_FOR: 'update' }, platform)
304
+ const stop = lastLine(r.stdout, (o) => o.stop)
305
+ if (!stop) return { ok: false, wasRunning: false, message: r.stderr.trim().split(/\r?\n/).at(-1) || `serve --stop gave no answer (exit ${r.code})` }
306
+ return { ok: stop.ok === true, wasRunning: stop.wasRunning === true, message: typeof stop.message === 'string' ? stop.message : null }
307
+ }
308
+
309
+ /**
310
+ * What answers within `ms`, by the connection line of the version `asker`: `{ ok: true }` once a host answers as
311
+ * `version` (null: any), `{ ok: false, other }` at once when one answers as another version, `{ ok: false }` when none
312
+ * did. Asked at least once
313
+ */
314
+ const hear = async (asker, version, ms = CHECK_MS) => {
315
+ const until = Date.now() + ms
316
+ for (;;) {
317
+ const r = await serveOf(l, asker, ['--connection'], env, platform)
318
+ const said = lastLine(r.stdout, (o) => (typeof o.port === 'number' ? o : undefined))
319
+ if (said?.hostRunning === true && (version === null || said.version === version)) return { ok: true }
320
+ if (said?.hostRunning === true && typeof said.version === 'string') return { ok: false, other: said.version }
321
+ if (Date.now() >= until) return { ok: false }
322
+ await sleep(1_000)
323
+ }
324
+ }
325
+
326
+ /**
327
+ * `v` started and answering as itself; otherwise why not. A start refused for WMI is left to the hub's link
328
+ * (`unchecked`). A host of another version found there instead was started meanwhile by something that does not ask
329
+ * the switch (a version from before it, through the sign-in task, the person or another hub): it is stopped and `v`
330
+ * started again, twice at most (#1907)
331
+ */
332
+ const startAndCheck = async (v, [startStep, checkStep] = ['start', 'check']) => {
333
+ for (let stopped = 0; ; stopped++) {
334
+ step(startStep)
335
+ const r = await serveOf(l, v, ['--detach'], env, platform)
336
+ const detach = lastLine(r.stdout, (o) => o.detach)
337
+ if (detach?.ok === false && detach.reason === 'wmi_blocked') return { why: null, unchecked: detach.message ?? 'WMI is blocked' }
338
+ const failed = detach?.ok === false ? (detach.message ?? 'it did not start') : !detach ? r.stderr.trim().split(/\r?\n/).at(-1) || `serve --detach gave no answer (exit ${r.code})` : null
339
+ if (checkStep) step(checkStep)
340
+ // A start that failed may have met a host starting meanwhile: a few seconds tell
341
+ const heard = await hear(v, v.version, failed ? 5_000 : CHECK_MS)
342
+ if (heard.ok) return { why: null }
343
+ if (!heard.other) return { why: failed ?? NO_ANSWER }
344
+ if (stopped === 2) return { why: `Centralu ${heard.other} answered there instead; something started it again during the switch` }
345
+ write({ startedMeanwhile: heard.other })
346
+ await stopWith(from && heard.other === from.version ? from : v)
347
+ }
348
+ }
349
+
350
+ /** The old version back after `why`: the new host stopped, the pointers as they were, the old host started and heard */
351
+ const putBack = async (why, extra = {}) => {
352
+ // `why` is kept from here, so a reader that finishes this put-back can still say it
353
+ write({ step: 'roll_back', why })
354
+ const log = hostLogTail(dataDir)
355
+ const stop = await stopWith(readPointerFile(l.current) ?? to)
356
+ const stopFailed = stop.ok ? null : stop.message
357
+ try {
358
+ setPointers(dataDir, from, state.was.previous, platform)
359
+ if (state.saveStore && from) storeRestore(dataDir, from.version)
360
+ if (state.wasRunning !== false) {
361
+ if (from) {
362
+ const back = await startAndCheck(from, ['restart', null])
363
+ if (back.why) throw new Error(back.why === NO_ANSWER ? `${NO_ANSWER} either` : back.why)
364
+ } else {
365
+ await startNpm(dataDir, env, platform)
366
+ if (!(await hear(to, null)).ok) throw new Error(`${NO_ANSWER} either`)
367
+ }
368
+ }
369
+ } catch (err) {
370
+ return write({ outcome: 'failed', stage: 'put_back', why, putBackWhy: err.message, stopFailed, log, ...extra })
371
+ }
372
+ return write({ outcome: 'put_back', why, log, ...extra })
373
+ }
374
+
375
+ return { step, write, stopWith, hear, startAndCheck, putBack, from, to }
376
+ }
377
+
378
+ /** The Centralu an npm install keeps its launcher for (`<data>/bin/centralu`), started again after a failed first switch */
379
+ function startNpm(dataDir, env, platform) {
380
+ const win = platform === 'win32'
381
+ const launcher = join(dataDir, 'bin', win ? 'centralu.cmd' : 'centralu')
382
+ if (!existsSync(launcher)) throw new Error('the Centralu that ran before has no launcher in the data folder')
383
+ return new Promise((resolve, reject) => {
384
+ const child = spawn(win ? `"${launcher}"` : launcher, ['serve', '--detach'], { env, stdio: 'ignore', shell: win, windowsHide: true })
385
+ child.once('error', reject)
386
+ child.once('close', () => resolve())
387
+ })
388
+ }
389
+
390
+ /**
391
+ * The switch itself (plan §10.5 steps 2 to 6): stop the running host, switch the pointers, start and check the new
392
+ * version, then prune; or put the old version back. `onLocked` runs once the lock is this one's. Returns the outcome
393
+ * written, or null when a live switcher holds the lock
394
+ */
395
+ export async function runSwitcher({ dataDir, params, env, platform = process.platform, onLocked = () => {} }) {
396
+ const l = remoteLayout(dataDir)
397
+ mkdirSync(l.root, { recursive: true })
398
+ const release = takeLock(l.root, params.id)
399
+ if (!release) return null
400
+ try {
401
+ const state = { v: 1, ...params, from: params.was.current, step: 'stop', startedAt: Date.now(), pid: process.pid, started: processStartTime(process.pid) }
402
+ const w = switchWork(dataDir, state, { env, platform })
403
+ w.write({})
404
+ onLocked(state)
405
+ const { from, to } = w
406
+ const stop = await w.stopWith(from ?? to)
407
+ if (!stop.ok) return w.write({ outcome: 'failed', stage: 'stop', why: stop.message ?? 'no reason given' })
408
+ w.write({ wasRunning: stop.wasRunning })
409
+ if (params.saveStore && from) {
410
+ w.step('backup')
411
+ try {
412
+ storeBackup(dataDir, from.version, to.version)
413
+ } catch (err) {
414
+ // Nothing was switched: the old version starts again as it was
415
+ if (stop.wasRunning) await w.startAndCheck(from, ['restart', null])
416
+ return w.write({ outcome: 'failed', stage: 'backup', why: err.message })
417
+ }
418
+ }
419
+ w.step('switch')
420
+ try {
421
+ setPointers(dataDir, to, params.keep, platform)
422
+ } catch (err) {
423
+ // `await`: the lock is released in `finally` only once the put-back is done
424
+ return await w.putBack(err.message)
425
+ }
426
+ let started = await w.startAndCheck(to)
427
+ let restored = false
428
+ // Leaving a dev build for a release that refuses the store the dev build left: the copy taken for it, and one more start (#1036)
429
+ if (started.why && !params.saveStore && LOCAL_BUILD.test(from?.version ?? '') && !LOCAL_BUILD.test(to.version)) {
430
+ w.step('restore_store')
431
+ await w.stopWith(to)
432
+ if (storeRestore(dataDir, to.version)) {
433
+ restored = true
434
+ started = await w.startAndCheck(to)
435
+ }
436
+ }
437
+ if (started.why) return await w.putBack(started.why)
438
+ return finishDone(dataDir, w, { ...(restored ? { restored } : {}), ...(started.unchecked ? { unchecked: started.unchecked } : {}) })
439
+ } finally {
440
+ release()
441
+ }
442
+ }
443
+
444
+ /** The new version answered: what no pointer, nor the switch's runner, names is removed, and the switch is `done` */
445
+ function finishDone(dataDir, w, extra = {}) {
446
+ w.step('prune')
447
+ let pruned = { removed: [], left: [] }
448
+ try {
449
+ pruned = prune(dataDir)
450
+ } catch {
451
+ // the next switch prunes again
452
+ }
453
+ return w.write({ outcome: 'done', ...pruned, ...extra })
454
+ }
455
+
456
+ /**
457
+ * A switch with no outcome whose switcher is not alive, finished by whoever read it, by the last step written: before
458
+ * the switch nothing changed (`failed`); from the switch until the check passed the old version is put back
459
+ * (`put_back`); after it (`prune`), `done`.
460
+ *
461
+ * What answers there decides first, and a reader never stops a host the switch did not start (#1907):
462
+ * - the version this part of the switch starts (the new one from the switch to the check, the old one in the
463
+ * put-back) answering as itself is taken as it is: `done`, or `put_back`; asked within the time the switcher would
464
+ * have given it, or once when that is past;
465
+ * - any other version answering, except the new one in the put-back (which the switch started and is ending), was
466
+ * started by someone else meanwhile (a version from before this switch asks none). It keeps serving: the old one
467
+ * with the pointers back as they were (`put_back`, `kept`), another with nothing changed (`failed`, stage `kept`).
468
+ * `start`: a host is started too (the old one, or the one that runs when nothing was switched); a caller that starts
469
+ * one itself next passes false. Answers the switch as it is now
470
+ */
471
+ export async function reconcile(dataDir, { env, platform = process.platform, start }) {
472
+ const s = readSwitch(dataDir)
473
+ if (!s || s.outcome) return s
474
+ const l = remoteLayout(dataDir)
475
+ if (lockIsLive(readJson(join(l.root, SWITCH_LOCK)))) return s
476
+ const release = takeLock(l.root, s.id)
477
+ if (!release) return readSwitch(dataDir)
478
+ try {
479
+ const again = readSwitch(dataDir)
480
+ if (!again || again.outcome) return again
481
+ const w = switchWork(dataDir, again, { env, platform })
482
+ if (again.step === 'prune') return finishDone(dataDir, w, { interrupted: true })
483
+ const now = readPointerFile(l.current)
484
+ const back = ['roll_back', 'restart'].includes(again.step)
485
+ const old = again.was.current
486
+ if (now && (back || ['switch', 'start', 'check'].includes(again.step))) {
487
+ // What this part of the switch starts; only it is waited for, and only when the pointers name it
488
+ const own = back ? old?.version : again.to.version
489
+ const left = (typeof again.at === 'number' ? again.at : 0) + (again.step === 'start' || again.step === 'restart' ? 2 : 1) * CHECK_MS - Date.now()
490
+ const heard = own ? await w.hear(now, own, now.version === own ? Math.max(0, left) : 0) : { ok: false }
491
+ if (heard.ok && now.version === own) {
492
+ return back ? w.write({ outcome: 'put_back', why: again.why ?? 'interrupted', interrupted: true }) : finishDone(dataDir, w, { interrupted: true })
493
+ }
494
+ const other = heard.ok ? own : heard.other
495
+ if (other && !(back && other === again.to.version)) {
496
+ const why = `Centralu ${other} was started there during the switch`
497
+ if (old && other === old.version) {
498
+ setPointers(dataDir, old, again.was.previous, platform)
499
+ return w.write({ outcome: 'put_back', why, kept: other, interrupted: true })
500
+ }
501
+ return w.write({ outcome: 'failed', stage: 'kept', why, kept: other, interrupted: true })
502
+ }
503
+ }
504
+ if (again.step === 'stop' || again.step === 'backup') {
505
+ w.write({ outcome: 'failed', stage: 'interrupted', why: 'interrupted before the switch', interrupted: true })
506
+ const now = readPointerFile(l.current)
507
+ if (start && now) await w.startAndCheck(now, ['restart', null])
508
+ return again
509
+ }
510
+ if (!start) again.wasRunning = false
511
+ return await w.putBack(again.why ?? 'interrupted', { interrupted: true })
512
+ } finally {
513
+ release()
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Before `serve` or `--detach` starts a host (§10.5a "No host starts beside a switch"): `{ go: true }`; `{ go: false,
519
+ * by, startedAt }` while a live switcher is between a stop and its start; `{ go: false, rerun }` when a switcher that died
520
+ * was finished here and `current` now names another version than this one (`version`), which then serves instead
521
+ */
522
+ export async function beforeHostStart({ dataDir, env, version, platform = process.platform }) {
523
+ const s = readSwitch(dataDir)
524
+ if (!s || s.outcome) return { go: true }
525
+ const l = remoteLayout(dataDir)
526
+ if (lockIsLive(readJson(join(l.root, SWITCH_LOCK)))) return QUIET_STEPS.has(s.step) ? { go: false, by: s.by, startedAt: s.startedAt } : { go: true }
527
+ await reconcile(dataDir, { env, platform, start: false })
528
+ const now = readPointerFile(l.current)
529
+ if (env.CENTRALU_MANAGED === '1' && now && now.version !== version) return { go: false, rerun: now }
530
+ return { go: true }
531
+ }
532
+
533
+ /** What `--detach` and `serve` say while a switch is under way there */
534
+ export function switchingMessage(gate) {
535
+ return `an update from ${gate.by} started at ${clockOf(gate.startedAt)} is switching versions here; no host starts until it ends`
536
+ }
537
+
538
+ /**
539
+ * `serve`, `--detach` and `--window` ask this before anything else (centralu.mjs), so a switcher that died is finished
540
+ * whatever this version would refuse next: a host already answering, a missing host entry, a Node below the floor (#1907).
541
+ * Null: go on. Otherwise the exit code, having said why or run the version `current` names now
542
+ */
543
+ export async function gateHostStart({ env, home, version, detach, platform = process.platform, write = (s) => process.stdout.write(s) }) {
544
+ const dataDir = serveDataDir(env, home)
545
+ const gate = await beforeHostStart({ dataDir, env, version, platform })
546
+ if (gate.go) return null
547
+ if (gate.rerun) return rerunAs(dataDir, gate.rerun, process.argv.slice(2), env, platform)
548
+ const message = switchingMessage(gate)
549
+ if (detach) return answerLine(write, 'detach', { ok: false, how: detachHow(platform, env), reason: 'switching', by: gate.by, startedAt: gate.startedAt, message })
550
+ say(message)
551
+ return 1
552
+ }
553
+
554
+ /** Runs `centralu serve <args>` of the version `current` names, in this terminal; answers its exit code */
555
+ export function rerunAs(dataDir, v, args, env, platform = process.platform) {
556
+ const { node, cli } = versionPaths(remoteLayout(dataDir), v, platform)
557
+ return new Promise((resolve) => {
558
+ const child = spawn(node, [cli, ...args], { env: { ...env, CENTRALU_MANAGED: '1' }, stdio: 'inherit', windowsHide: true })
559
+ child.once('error', () => resolve(1))
560
+ child.once('close', (code) => resolve(code ?? 1))
561
+ })
562
+ }
563
+
564
+ /** The variable the hub's handover names the running version in (`<version> <node>`), written to `switch.runner` */
565
+ export const RUNNER_ENV = 'CENTRALU_SWITCH_RUNNER'
566
+
567
+ /**
568
+ * `centralu serve --switch <arg>`, the handover: starts the switcher outside this ssh session (`detachHow`, as
569
+ * `--detach` starts a host), waits until it wrote its pid, prints `{ v: 1, switch: { ok: true, id, pid } }` and exits.
570
+ * When nothing can be started outside the session (WMI blocked, a `wsl.exe` that never ran), or the one started never
571
+ * wrote its pid, the switch runs here in the session: a link drop then ends it, and the next reader finishes it
572
+ * (`reconcile`). `{ ok: false, reason: 'busy', by, startedAt }` while another switch runs there, having changed
573
+ * nothing: `switch.runner` is written only once this one may run
574
+ */
575
+ export async function runSwitch({ env, home, raw, cliPath, execPath = process.execPath, platform = process.platform, start, write = (s) => process.stdout.write(s) }) {
576
+ const params = decodeSwitch(raw)
577
+ if (!params) return answerLine(write, 'switch', { ok: false, reason: 'params', message: 'this is not a switch serve --switch can read' })
578
+ const dataDir = serveDataDir(env, home)
579
+ const l = remoteLayout(dataDir)
580
+ mkdirSync(l.root, { recursive: true })
581
+ await reconcile(dataDir, { env, platform, start: false })
582
+ const busy = () => {
583
+ const s = readSwitch(dataDir)
584
+ return answerLine(write, 'switch', { ok: false, reason: 'busy', by: s?.by ?? null, startedAt: s?.startedAt ?? null, message: `another switch runs there${s ? `, from ${s.by} since ${clockOf(s.startedAt)}` : ''}` })
585
+ }
586
+ if (lockIsLive(readJson(join(l.root, SWITCH_LOCK)))) return busy()
587
+ const runner = parsePointer(env[RUNNER_ENV])
588
+ if (runner) replaceFile(join(l.root, SWITCH_RUNNER), line(runner))
589
+ const startOutside = start ?? (await import('./serve-detach.mjs')).startDetached
590
+ const created = await startOutside({ how: detachHow(platform, env), execPath, args: [cliPath, 'serve', '--switch-run', raw, ...dataDirArg(env)], env: { ...env, CENTRALU_MANAGED: '1' }, cwd: dataDir })
591
+ if (created.ok && (await waitUntil(() => readSwitch(dataDir)?.id === params.id, HANDOVER_MS))) {
592
+ return answerLine(write, 'switch', { ok: true, id: params.id, pid: readSwitch(dataDir).pid })
593
+ }
594
+ // Never ran, or not yet: what was created is ended (WSL's `wsl.exe` that hangs, §10.4), and the switch runs here
595
+ if (created.ok && readSwitch(dataDir)?.id !== params.id) await created.stop?.()
596
+ let said = false
597
+ const done = await runSwitcher({
598
+ dataDir,
599
+ params,
600
+ env,
601
+ platform,
602
+ onLocked: () => {
603
+ said = true
604
+ answerLine(write, 'switch', { ok: true, id: params.id, pid: process.pid, inSession: true })
605
+ },
606
+ })
607
+ if (said) return 0
608
+ // The detached one took the lock after all
609
+ const s = readSwitch(dataDir)
610
+ return s?.id === params.id ? answerLine(write, 'switch', { ok: true, id: params.id, pid: s.pid }) : done === null ? busy() : 1
611
+ }
612
+
613
+ /** `centralu serve --switch-run <arg> [<data folder>]`: the detached switcher `runSwitch` started */
614
+ export async function runSwitchRun({ env, home, raw, dataDir: dataArg = null, platform = process.platform }) {
615
+ const params = decodeSwitch(raw)
616
+ if (!params) return 2
617
+ const runEnv = withDataDir(env, dataArg)
618
+ const done = await runSwitcher({ dataDir: serveDataDir(runEnv, home), params, env: runEnv, platform })
619
+ return done ? 0 : 1
620
+ }
621
+
622
+ /**
623
+ * `centralu serve --switch-status [--seen <id>]`: `{ v: 1, switchStatus: <switch.json> | null }`. A switch whose
624
+ * switcher died is finished first, its old version started (`reconcile`). `--seen <id>`: that switch's outcome was
625
+ * reported to the person, so a hub that links later does not report it again
626
+ */
627
+ export async function runSwitchStatus({ env, home, seen = null, platform = process.platform, write = (s) => process.stdout.write(s) }) {
628
+ const dataDir = serveDataDir(env, home)
629
+ let s = await reconcile(dataDir, { env, platform, start: true })
630
+ if (s && seen && s.id === seen && s.outcome && !s.seen) {
631
+ s = { ...s, seen: true }
632
+ replaceFile(join(remoteLayout(dataDir).root, SWITCH_FILE), `${JSON.stringify(s)}\n`)
633
+ }
634
+ write(`${JSON.stringify({ v: 1, switchStatus: s })}\n`)
635
+ return 0
636
+ }
package/bin/serve.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  * folder (`serve-files.mjs`), the port (`serve-port.mjs`), the launchers and the managed install
22
22
  * (`serve-install.mjs`), the host and its socket (`serve-host.mjs`), the foreground serve
23
23
  * (`serve-run.mjs`), `--detach` and `--stop` (`serve-detach.mjs`), `--autostart`
24
- * (`serve-autostart.mjs`), Windows, WSL and PowerShell (`serve-windows.mjs`), and small helpers
24
+ * (`serve-autostart.mjs`), the hub's version switch (`serve-switch.mjs`), Windows, WSL and PowerShell (`serve-windows.mjs`), and small helpers
25
25
  * (`serve-util.mjs`).
26
26
  */
27
27
  export { SERVE_STATE_FILE, SERVE_LOG_FILE, SERVE_PID_FILE, serveDataDir, readServeState, ensureServeState, recordServePort, rotateServeToken, pairServe, pairKeyServe, dropVerifierUnder, revokeServe, verifierOf, limitedOf, changeServeState, heldHere, startedByServe, foreignHolder, holderWho, lockHolderIsAlive, processStartTime, processCreatedAt, lockHolder, lockHolderPid, readServePid, SERVE_STOPPED_FILE, SERVE_LOGS_KEPT, markStopped, readStopped, rotateServeLog } from './serve-files.mjs'
@@ -36,3 +36,4 @@ export { runWindow } from './serve-window.mjs'
36
36
  export { AUTOSTART_FILE, AUTOSTART_ENV, autostartHow, autostartUnsupported, pathKey, autostartName, autostartLauncher, systemdUnit, taskActionScript, taskScript, parseTaskAnswer, readAutostartMark, autostartInfo, lingerMessage, runAutostart, startInSignIn } from './serve-autostart.mjs'
37
37
  export { printConnection, runServe, rotateToken, revokeCredential } from './serve-run.mjs'
38
38
  export { CARRIED_ENV, encodeChildSpec, decodeChildSpec, startDetached, runDetach, runDetachedChild, runStop } from './serve-detach.mjs'
39
+ export { SWITCH_FILE, SWITCH_LOCK, SWITCH_RUNNER, lockIsLive, decodeSwitch, readSwitch, setPointers, prune, storeBackup, storeRestore, hostLogTail, runSwitcher, reconcile, beforeHostStart, gateHostStart, clockOf, RUNNER_ENV, rerunAs, runSwitch, runSwitchRun, runSwitchStatus } from './serve-switch.mjs'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "centralu",
3
- "version": "0.1.0-beta.46",
3
+ "version": "0.1.0-beta.47",
4
4
  "description": "Run, watch and steer several Claude Code and Codex CLI sessions from one window",
5
5
  "license": "MIT",
6
6
  "author": "ijun17",
@@ -38,9 +38,9 @@
38
38
  "win32"
39
39
  ],
40
40
  "optionalDependencies": {
41
- "@centralu/darwin-arm64": "0.1.0-beta.46",
42
- "@centralu/linux-arm64": "0.1.0-beta.46",
43
- "@centralu/linux-x64": "0.1.0-beta.46",
44
- "@centralu/win32-x64": "0.1.0-beta.46"
41
+ "@centralu/darwin-arm64": "0.1.0-beta.47",
42
+ "@centralu/linux-arm64": "0.1.0-beta.47",
43
+ "@centralu/linux-x64": "0.1.0-beta.47",
44
+ "@centralu/win32-x64": "0.1.0-beta.47"
45
45
  }
46
46
  }