centralu 0.1.0-beta.12 → 0.1.0-beta.14
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 +53 -3
- package/bin/platform.mjs +10 -0
- package/bin/serve.mjs +803 -44
- package/package.json +5 -5
package/bin/centralu.mjs
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { execFileSync, spawn } from 'node:child_process'
|
|
26
26
|
import { copyDiffers, isNewer } from './semver.mjs'
|
|
27
27
|
import {
|
|
28
|
+
asideCopies,
|
|
28
29
|
busyMessage,
|
|
29
30
|
earlyExitMessage,
|
|
30
31
|
executableIn,
|
|
@@ -41,8 +42,18 @@ import {
|
|
|
41
42
|
webview2Status,
|
|
42
43
|
windowsInstall,
|
|
43
44
|
} from './platform.mjs'
|
|
44
|
-
import {
|
|
45
|
-
|
|
45
|
+
import {
|
|
46
|
+
decodeChildSpec,
|
|
47
|
+
parseServeArgs,
|
|
48
|
+
printConnection,
|
|
49
|
+
rotateToken,
|
|
50
|
+
runDetach,
|
|
51
|
+
runDetachedChild,
|
|
52
|
+
runServe,
|
|
53
|
+
runStop,
|
|
54
|
+
SERVE_HELP,
|
|
55
|
+
} from './serve.mjs'
|
|
56
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
46
57
|
import { createRequire } from 'node:module'
|
|
47
58
|
import { homedir } from 'node:os'
|
|
48
59
|
import { dirname, join } from 'node:path'
|
|
@@ -322,6 +333,7 @@ function installDesktopEntry(app) {
|
|
|
322
333
|
function installWindows(app) {
|
|
323
334
|
const staging = `${WIN.dir}.new`
|
|
324
335
|
const aside = `${WIN.dir}.old-${Date.now()}`
|
|
336
|
+
sweepAsideCopies()
|
|
325
337
|
rmSync(staging, { recursive: true, force: true })
|
|
326
338
|
mkdirSync(dirname(WIN.dir), { recursive: true })
|
|
327
339
|
cpSync(app, staging, { recursive: true })
|
|
@@ -358,6 +370,32 @@ function installWindows(app) {
|
|
|
358
370
|
console.log('It can now be found in Start and Windows search, and `centralu` starts this copy too.')
|
|
359
371
|
}
|
|
360
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Removes the copies earlier installs renamed aside (`Centralu.old-<time>`), whichever can go.
|
|
375
|
+
*
|
|
376
|
+
* An update from inside the app renames the running copy aside, and Windows will not delete a
|
|
377
|
+
* running exe, so that copy stays until the app quits. Nothing else would ever remove it, and
|
|
378
|
+
* each in-app update would leave one more (about 70 files each, measured 2026-10-08). One still
|
|
379
|
+
* in use is left for the next install; deleting what can be deleted from it does not disturb the
|
|
380
|
+
* app running from it, which reaches its files by the folder's original path, now the new copy.
|
|
381
|
+
*/
|
|
382
|
+
function sweepAsideCopies() {
|
|
383
|
+
const parent = dirname(WIN.dir)
|
|
384
|
+
let names
|
|
385
|
+
try {
|
|
386
|
+
names = readdirSync(parent)
|
|
387
|
+
} catch {
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
for (const name of asideCopies(names)) {
|
|
391
|
+
try {
|
|
392
|
+
rmSync(join(parent, name), { recursive: true, force: true })
|
|
393
|
+
} catch {
|
|
394
|
+
// Still in use: the next install tries again
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
361
399
|
function uninstall() {
|
|
362
400
|
const paths = installedPaths(PLATFORM, process.env, HOME)
|
|
363
401
|
const present = paths.filter((p) => existsSync(p))
|
|
@@ -551,9 +589,21 @@ switch (cmd) {
|
|
|
551
589
|
process.exitCode = rotateToken({ env: process.env, home: HOME })
|
|
552
590
|
break
|
|
553
591
|
}
|
|
592
|
+
// A launcher `--detach` started: what it carried (the data folder, a host entry) applies before
|
|
593
|
+
// anything below reads the environment
|
|
594
|
+
const spec = args.child !== undefined ? decodeChildSpec(args.child) : null
|
|
595
|
+
if (args.child !== undefined && !spec) {
|
|
596
|
+
console.error('centralu serve: --detached-child is how --detach starts serve; this one could not be read')
|
|
597
|
+
process.exit(2)
|
|
598
|
+
}
|
|
599
|
+
if (spec) Object.assign(process.env, spec.env)
|
|
554
600
|
// realpath: npm's bin is a symlink to this file, and the launcher must not depend on the link
|
|
555
601
|
const opts = { env: process.env, home: HOME, entry: requireHostEntry(), version: pkg.version, cliPath: realpathSync(process.argv[1]) }
|
|
556
|
-
|
|
602
|
+
if (args.mode === 'connection') process.exitCode = await printConnection(opts)
|
|
603
|
+
else if (args.mode === 'detach') process.exitCode = await runDetach({ ...opts, port: args.port })
|
|
604
|
+
else if (args.mode === 'stop') process.exitCode = await runStop(opts)
|
|
605
|
+
else if (spec) process.exitCode = await runDetachedChild({ ...opts, spec, port: args.port })
|
|
606
|
+
else process.exitCode = await runServe({ ...opts, port: args.port })
|
|
557
607
|
} catch (e) {
|
|
558
608
|
// A state file that cannot be read or written: one sentence, not a stack, and nothing on stdout
|
|
559
609
|
console.error(`centralu serve: ${e?.message ?? e}`)
|
package/bin/platform.mjs
CHANGED
|
@@ -137,6 +137,16 @@ export function windowsInstall(env, home) {
|
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Which of the names in `%LOCALAPPDATA%\Programs` are copies an earlier `centralu install` renamed
|
|
142
|
+
* aside (`Centralu.old-<time>`). Only those: the folder is shared with every other per-user
|
|
143
|
+
* program, and `Centralu` itself and a `Centralu.new` being assembled are not leftovers.
|
|
144
|
+
*/
|
|
145
|
+
export function asideCopies(names) {
|
|
146
|
+
const prefix = `${APP_NAME}.old-`.toLowerCase()
|
|
147
|
+
return names.filter((n) => n.toLowerCase().startsWith(prefix) && /^\d+$/.test(n.slice(prefix.length)))
|
|
148
|
+
}
|
|
149
|
+
|
|
140
150
|
/**
|
|
141
151
|
* What `centralu install` creates, per platform. `uninstall` removes exactly these and
|
|
142
152
|
* `update` refreshes them when the first one exists.
|
package/bin/serve.mjs
CHANGED
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { spawn } from 'node:child_process'
|
|
19
19
|
import { randomBytes } from 'node:crypto'
|
|
20
|
-
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeSync, closeSync } from 'node:fs'
|
|
21
|
-
import { connect } from 'node:net'
|
|
20
|
+
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync, closeSync } from 'node:fs'
|
|
21
|
+
import { connect, createServer } from 'node:net'
|
|
22
|
+
import { userInfo } from 'node:os'
|
|
22
23
|
import { dirname, join } from 'node:path'
|
|
23
24
|
import { createInterface } from 'node:readline'
|
|
24
25
|
|
|
@@ -33,6 +34,70 @@ import { createInterface } from 'node:readline'
|
|
|
33
34
|
*/
|
|
34
35
|
export const DEFAULT_SERVE_PORT = 17175
|
|
35
36
|
|
|
37
|
+
/** How many ports from the recorded one `serve` tries when it is taken (#482); none at or above 20000 */
|
|
38
|
+
export const SERVE_PORT_TRIES = 20
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The port `serve` starts the host on (#482): `{ port, from }`, `from` being the port it moved away
|
|
42
|
+
* from (null when it did not), or null when every candidate is taken.
|
|
43
|
+
*
|
|
44
|
+
* A port the person gave (`--port`) is used as given: they asked for that one. Otherwise the recorded
|
|
45
|
+
* port (else the default) when it is free, and when another program listens there, the next free
|
|
46
|
+
* one above it, below 20000 (the ranges `DEFAULT_SERVE_PORT` keeps out of). The host's ready line
|
|
47
|
+
* then records the port it got, so `--connection` reports it and the hub forwards to it.
|
|
48
|
+
*
|
|
49
|
+
* The usual other program is the other half of one Windows laptop: WSL2 relays a distro's
|
|
50
|
+
* 127.0.0.1 ports to Windows (`wslrelay.exe`), so a Windows install and a WSL install linked as two
|
|
51
|
+
* machines both wanted 17175, and whichever started second could not start at all.
|
|
52
|
+
* `taken(port)` answers whether something listens there.
|
|
53
|
+
*/
|
|
54
|
+
export async function chooseServePort({ asked, recorded, taken }) {
|
|
55
|
+
if (asked !== null && asked !== undefined) return { port: asked, from: null }
|
|
56
|
+
const first = recorded ?? DEFAULT_SERVE_PORT
|
|
57
|
+
for (let p = first; p < first + SERVE_PORT_TRIES && p <= 65535; p++) {
|
|
58
|
+
if (p !== first && p >= 20000) break
|
|
59
|
+
if (!(await taken(p))) return { port: p, from: p === first ? null : first }
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whether something listens on 127.0.0.1:`port` here: a listen that fails with EADDRINUSE (or EACCES) */
|
|
65
|
+
export function listensHere(port) {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
const srv = createServer()
|
|
68
|
+
srv.once('error', (e) => resolve(e?.code === 'EADDRINUSE' || e?.code === 'EACCES'))
|
|
69
|
+
srv.listen({ host: '127.0.0.1', port, exclusive: true }, () => srv.close(() => resolve(false)))
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The TCP ports Windows listens on, from `netstat.exe -an -p TCP` output: a listening socket is the
|
|
75
|
+
* row whose foreign address is `0.0.0.0:0` (or `[::]:0`). Read that way rather than by the state
|
|
76
|
+
* column, which Windows translates (a Korean Windows does not print LISTENING everywhere).
|
|
77
|
+
*/
|
|
78
|
+
export function parseNetstatListening(text) {
|
|
79
|
+
const ports = new Set()
|
|
80
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
81
|
+
const m = /^\s*TCP\s+(?:127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d+)\s+(?:0\.0\.0\.0:0|\[::\]:0)\s/i.exec(line)
|
|
82
|
+
if (m) ports.add(Number(m[1]))
|
|
83
|
+
}
|
|
84
|
+
return ports
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The ports the Windows side listens on, seen from inside a WSL distro (empty elsewhere, or when
|
|
89
|
+
* Windows cannot be asked). Inside the distro a port Windows holds looks free, yet a host there
|
|
90
|
+
* would be unreachable from the hub: the hub's forward ends on Windows' 127.0.0.1, where the
|
|
91
|
+
* Windows program answers instead (#482).
|
|
92
|
+
*/
|
|
93
|
+
async function windowsListening(env, platform) {
|
|
94
|
+
if (detachHow(platform, env) !== 'wsl') return new Set()
|
|
95
|
+
const netstat = windowsTool('wsl', env, ['System32', 'netstat.exe'])
|
|
96
|
+
if (!netstat) return new Set()
|
|
97
|
+
const r = await runCaptured(netstat, ['-an', '-p', 'TCP'], env)
|
|
98
|
+
return r.code === 0 ? parseNetstatListening(r.stdout) : new Set()
|
|
99
|
+
}
|
|
100
|
+
|
|
36
101
|
/**
|
|
37
102
|
* The file in the data folder that holds the token and the port, mode 0600.
|
|
38
103
|
*
|
|
@@ -43,36 +108,62 @@ export const DEFAULT_SERVE_PORT = 17175
|
|
|
43
108
|
*/
|
|
44
109
|
export const SERVE_STATE_FILE = 'serve.json'
|
|
45
110
|
|
|
111
|
+
/** What a detached serve writes: its own lines, and the host's until it is ready (host.log has the rest) */
|
|
112
|
+
export const SERVE_LOG_FILE = 'serve.log'
|
|
113
|
+
|
|
114
|
+
/** The running launcher's pid and port, so `--stop` can find it. Removed when it exits */
|
|
115
|
+
export const SERVE_PID_FILE = 'serve.pid'
|
|
116
|
+
|
|
46
117
|
export const SERVE_HELP = `centralu serve: run the Centralu host on this machine, without a window
|
|
47
118
|
|
|
48
119
|
centralu serve Start the host in the foreground, on 127.0.0.1 only
|
|
49
120
|
centralu serve --port <n> ... on that port (kept for the next start; default ${DEFAULT_SERVE_PORT})
|
|
121
|
+
centralu serve --detach Start it in the background, outside this session; return once it answers
|
|
122
|
+
centralu serve --stop Ask the running host to stop, and wait until it has
|
|
50
123
|
centralu serve --connection Print how to reach it as one JSON line, then exit
|
|
51
124
|
centralu serve --rotate-token Replace the token (a running serve keeps the old one until restarted)
|
|
52
125
|
|
|
53
126
|
The data folder is ~/.centralu (CC_DATA_DIR overrides it). The app on another computer reaches
|
|
54
|
-
this host through an SSH local forward
|
|
127
|
+
this host through an SSH local forward, and starts it with --detach when it is not running.
|
|
128
|
+
Stop a foreground one with Ctrl+C or SIGTERM, a detached one with --stop. A detached one writes
|
|
129
|
+
how its start went to serve.log in the data folder; the host's own log is host.log.
|
|
55
130
|
|
|
56
|
-
|
|
57
|
-
paths, for SSH sessions whose PATH has neither npm's global folder nor node
|
|
131
|
+
serve, --detach and --connection keep ~/.centralu/bin/centralu up to date: a launcher with absolute
|
|
132
|
+
paths, for SSH sessions whose PATH has neither npm's global folder nor node. Not when they run
|
|
133
|
+
from an install the app made over SSH, which has a launcher of its own.`
|
|
134
|
+
|
|
135
|
+
/** The flags that each make serve a different command */
|
|
136
|
+
const MODE_FLAGS = { '--connection': 'connection', '--rotate-token': 'rotate', '--detach': 'detach', '--stop': 'stop' }
|
|
137
|
+
const FLAG_OF = Object.fromEntries(Object.entries(MODE_FLAGS).map(([f, m]) => [m, f]))
|
|
58
138
|
|
|
59
139
|
/**
|
|
60
|
-
*
|
|
140
|
+
* The argument a detached launcher is started with: what it needs from the `--detach` that started
|
|
141
|
+
* it, which a process created through WMI does not inherit (it gets the account's default
|
|
142
|
+
* environment, not this one). Never a secret: the token stays in serve.json. Internal, not in the help.
|
|
143
|
+
*/
|
|
144
|
+
const CHILD_FLAG = '--detached-child='
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* What the arguments after `serve` ask for: `{ mode, port }` (plus `child` for a detached launcher)
|
|
148
|
+
* or `{ error }`.
|
|
61
149
|
*
|
|
62
|
-
* `mode` is `serve`, `connection`, `rotate` or `help`; `port` is null when not
|
|
63
|
-
*
|
|
150
|
+
* `mode` is `serve`, `detach`, `stop`, `connection`, `rotate` or `help`; `port` is null when not
|
|
151
|
+
* given. `--port` goes with `serve` and `--detach` only: `--connection` has to report the port
|
|
152
|
+
* `serve` uses, not one the caller names, and `--stop` stops the one that runs.
|
|
64
153
|
*/
|
|
65
154
|
export function parseServeArgs(argv) {
|
|
66
155
|
let mode = 'serve'
|
|
67
156
|
let port = null
|
|
157
|
+
let help = false
|
|
158
|
+
let child = null
|
|
68
159
|
for (let i = 0; i < argv.length; i++) {
|
|
69
160
|
const a = argv[i]
|
|
70
|
-
if (
|
|
71
|
-
const m = a
|
|
72
|
-
if (mode !== 'serve' && mode !==
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
else if (a
|
|
161
|
+
if (Object.hasOwn(MODE_FLAGS, a)) {
|
|
162
|
+
const m = MODE_FLAGS[a]
|
|
163
|
+
if (mode !== 'serve' && mode !== m) return { error: `${FLAG_OF[mode]} and ${a} are separate commands; pass one` }
|
|
164
|
+
mode = m
|
|
165
|
+
} else if (a === '--help' || a === '-h') help = true
|
|
166
|
+
else if (a.startsWith(CHILD_FLAG)) child = a.slice(CHILD_FLAG.length)
|
|
76
167
|
else if (a === '--port' || a.startsWith('--port=')) {
|
|
77
168
|
const raw = a === '--port' ? argv[++i] : a.slice('--port='.length)
|
|
78
169
|
const n = parsePort(raw)
|
|
@@ -80,9 +171,15 @@ export function parseServeArgs(argv) {
|
|
|
80
171
|
port = n
|
|
81
172
|
} else return { error: `unknown option for serve: ${a}\n\n${SERVE_HELP}` }
|
|
82
173
|
}
|
|
174
|
+
if (help) return { mode: 'help', port }
|
|
175
|
+
if (child !== null) {
|
|
176
|
+
if (mode !== 'serve') return { error: `${CHILD_FLAG.slice(0, -1)} is how --detach starts serve, not an option of ${FLAG_OF[mode]}` }
|
|
177
|
+
return { mode, port, child }
|
|
178
|
+
}
|
|
83
179
|
if ((mode === 'connection' || mode === 'rotate') && port !== null) {
|
|
84
180
|
return { error: '--port goes with `centralu serve`; `--connection` reports the port serve listens on' }
|
|
85
181
|
}
|
|
182
|
+
if (mode === 'stop' && port !== null) return { error: '--stop stops the serve that is running, on the port it recorded; it takes no --port' }
|
|
86
183
|
return { mode, port }
|
|
87
184
|
}
|
|
88
185
|
|
|
@@ -214,9 +311,16 @@ export function ensureLauncher(dataDir, { platform = process.platform, execPath
|
|
|
214
311
|
return file
|
|
215
312
|
}
|
|
216
313
|
|
|
217
|
-
/**
|
|
218
|
-
|
|
219
|
-
|
|
314
|
+
/**
|
|
315
|
+
* ensureLauncher, with a failure said on stderr instead of thrown: the launcher is a convenience.
|
|
316
|
+
*
|
|
317
|
+
* Not under the managed launcher (`CENTRALU_MANAGED=1`, docs/plans/remote-hub.md §10.1): an install
|
|
318
|
+
* the hub made over ssh has its own launcher in `<data>/remote/bin/`, which reads `current`. Writing
|
|
319
|
+
* `<data>/bin/centralu` from there would point an npm install's launcher at one managed version, and
|
|
320
|
+
* the two would rewrite each other's file on every start.
|
|
321
|
+
*/
|
|
322
|
+
function keepLauncher(dataDir, cliPath, env) {
|
|
323
|
+
if (!cliPath || env?.CENTRALU_MANAGED === '1') return
|
|
220
324
|
try {
|
|
221
325
|
ensureLauncher(dataDir, { cliPath })
|
|
222
326
|
} catch (e) {
|
|
@@ -269,9 +373,121 @@ export function protocolVersionOf(entry) {
|
|
|
269
373
|
return null
|
|
270
374
|
}
|
|
271
375
|
|
|
272
|
-
/**
|
|
273
|
-
|
|
274
|
-
|
|
376
|
+
/**
|
|
377
|
+
* The `--connection` answer. The shape is the client's interface (docs/agent-host.md §4.7).
|
|
378
|
+
* `install` (additive, the line stays `v: 1`) says what runs this command (`installInfo`); `detach`
|
|
379
|
+
* is added by `--detach` only.
|
|
380
|
+
*/
|
|
381
|
+
export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, detach }) {
|
|
382
|
+
return JSON.stringify({
|
|
383
|
+
v: 1,
|
|
384
|
+
port,
|
|
385
|
+
token,
|
|
386
|
+
version,
|
|
387
|
+
protocolVersion,
|
|
388
|
+
dataDir,
|
|
389
|
+
hostRunning,
|
|
390
|
+
...(install ? { install } : {}),
|
|
391
|
+
...(detach ? { detach } : {}),
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The install the hub manages on this machine (docs/plans/remote-hub.md §10.1): side-by-side
|
|
397
|
+
* versions, the pinned Node they run on, and two pointer files naming what runs and what to roll
|
|
398
|
+
* back to. The hub's installer writes it; `serve` only reads it.
|
|
399
|
+
*/
|
|
400
|
+
export function remoteLayout(dataDir) {
|
|
401
|
+
const root = join(dataDir, 'remote')
|
|
402
|
+
return {
|
|
403
|
+
root,
|
|
404
|
+
current: join(root, 'current'),
|
|
405
|
+
previous: join(root, 'previous'),
|
|
406
|
+
versions: join(root, 'versions'),
|
|
407
|
+
node: join(root, 'node'),
|
|
408
|
+
bin: join(root, 'bin'),
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** A word a pointer file may hold: a folder name, nothing a path could escape with */
|
|
413
|
+
const POINTER_WORD = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/
|
|
414
|
+
|
|
415
|
+
/** `current` / `previous`: one line, "<centralu version> <node version>". null for anything else */
|
|
416
|
+
export function parsePointer(text) {
|
|
417
|
+
const m = /^\s*(\S+)[ \t]+(\S+)\s*$/.exec(String(text ?? ''))
|
|
418
|
+
if (!m || !POINTER_WORD.test(m[1]) || !POINTER_WORD.test(m[2])) return null
|
|
419
|
+
return { version: m[1], node: m[2] }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The connection line's `install` field (docs/plans/remote-hub.md §10.5): whether this command was
|
|
424
|
+
* started by the managed launcher, the managed install's `current` and `previous`, and the Node
|
|
425
|
+
* running this command. Read from the machine at every ask, never kept by the hub (S12).
|
|
426
|
+
*/
|
|
427
|
+
export function installInfo(dataDir, env, nodeVersion = process.versions.node) {
|
|
428
|
+
const l = remoteLayout(dataDir)
|
|
429
|
+
const read = (file) => {
|
|
430
|
+
try {
|
|
431
|
+
return parsePointer(readFileSync(file, 'utf8'))
|
|
432
|
+
} catch {
|
|
433
|
+
return null
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return { managed: env.CENTRALU_MANAGED === '1', current: read(l.current), previous: read(l.previous), node: nodeVersion }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The managed launcher, `<data>/remote/bin/centralu` (`centralu.cmd` on Windows), as the installer
|
|
441
|
+
* writes it (docs/plans/remote-hub.md §10.1, S8).
|
|
442
|
+
*
|
|
443
|
+
* **Its text never changes.** It reads `current` at every start and runs that version's
|
|
444
|
+
* `centralu.mjs` on that version's Node, so switching versions is one file replaced by a rename,
|
|
445
|
+
* atomic on both systems. A launcher rewritten per version would be replaced while it may be
|
|
446
|
+
* running, and cmd.exe reads a batch file by offset as it runs. It sets `CENTRALU_MANAGED=1`, so
|
|
447
|
+
* the `serve` it starts leaves `<data>/bin/` to an npm install on the same machine.
|
|
448
|
+
*
|
|
449
|
+
* Both refuse a pointer that holds anything but plain version words, so an edited `current` cannot
|
|
450
|
+
* point it outside the install.
|
|
451
|
+
*/
|
|
452
|
+
export function managedLauncherScript(platform) {
|
|
453
|
+
if (platform === 'win32') {
|
|
454
|
+
return (
|
|
455
|
+
[
|
|
456
|
+
'@echo off',
|
|
457
|
+
'rem The Centralu launcher for the install the app manages over SSH. It never changes: it reads',
|
|
458
|
+
'rem "current" at every start, so switching versions replaces that file and never this one.',
|
|
459
|
+
'setlocal',
|
|
460
|
+
'set "CENTRALU_REMOTE=%~dp0.."',
|
|
461
|
+
'set "CENTRALU_LINE="',
|
|
462
|
+
'set "CENTRALU_V="',
|
|
463
|
+
'set "CENTRALU_N="',
|
|
464
|
+
'set /p CENTRALU_LINE=<"%CENTRALU_REMOTE%\\current"',
|
|
465
|
+
'for /f "tokens=1,2" %%a in ("%CENTRALU_LINE%") do (set "CENTRALU_V=%%a" & set "CENTRALU_N=%%b")',
|
|
466
|
+
'if not defined CENTRALU_N goto none',
|
|
467
|
+
'echo %CENTRALU_V% %CENTRALU_N%| findstr /r /c:"^[0-9A-Za-z][0-9A-Za-z.+-]* [0-9A-Za-z][0-9A-Za-z.+-]*$" >nul || goto none',
|
|
468
|
+
'set CENTRALU_MANAGED=1',
|
|
469
|
+
'"%CENTRALU_REMOTE%\\node\\v%CENTRALU_N%\\node.exe" "%CENTRALU_REMOTE%\\versions\\%CENTRALU_V%\\node_modules\\centralu\\bin\\centralu.mjs" %*',
|
|
470
|
+
'exit /b %ERRORLEVEL%',
|
|
471
|
+
':none',
|
|
472
|
+
'echo Centralu: no version is installed in %CENTRALU_REMOTE% 1>&2',
|
|
473
|
+
'exit /b 1',
|
|
474
|
+
].join('\r\n') + '\r\n'
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
return [
|
|
478
|
+
'#!/bin/sh',
|
|
479
|
+
'# The Centralu launcher for the install the app manages over SSH. It never changes: it reads',
|
|
480
|
+
'# `current` at every start, so switching versions replaces that file and never this one.',
|
|
481
|
+
'remote=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) || exit 1',
|
|
482
|
+
'v= n=',
|
|
483
|
+
'read -r v n < "$remote/current" 2>/dev/null',
|
|
484
|
+
'case "$v" in ""|[!0-9A-Za-z]*|*[!0-9A-Za-z.+-]*) echo "Centralu: no version is installed in $remote" >&2; exit 1 ;; esac',
|
|
485
|
+
'case "$n" in ""|[!0-9A-Za-z]*|*[!0-9A-Za-z.+-]*) echo "Centralu: no version is installed in $remote" >&2; exit 1 ;; esac',
|
|
486
|
+
'CENTRALU_MANAGED=1',
|
|
487
|
+
'export CENTRALU_MANAGED',
|
|
488
|
+
'exec "$remote/node/v$n/bin/node" "$remote/versions/$v/node_modules/centralu/bin/centralu.mjs" "$@"',
|
|
489
|
+
'',
|
|
490
|
+
].join('\n')
|
|
275
491
|
}
|
|
276
492
|
|
|
277
493
|
/**
|
|
@@ -285,12 +501,16 @@ export function connectionLine({ port, token, version, protocolVersion, dataDir,
|
|
|
285
501
|
* - Headless: no `DISPLAY` / `WAYLAND_DISPLAY`. Nothing the host or an agent starts gets a screen
|
|
286
502
|
* to open a window or a keyring prompt on, so a tool that would ask falls back to its file store
|
|
287
503
|
* or fails with a message in the log instead of waiting on a dialog nobody can see.
|
|
288
|
-
* - The keeper's and dev mode's variables are dropped: this host is neither.
|
|
504
|
+
* - The keeper's and dev mode's variables are dropped: this host is neither. So is
|
|
505
|
+
* `CENTRALU_MANAGED`, which is the launcher's business and would otherwise reach every agent.
|
|
506
|
+
* - `CC_SERVE=1` lets the host take `host.stop` (`--stop`); a host the app runs refuses it.
|
|
289
507
|
*/
|
|
290
508
|
export function hostEnv(env, { token, dataDir, version, hostDir }) {
|
|
291
509
|
const out = { ...env }
|
|
292
|
-
for (const k of ['DISPLAY', 'WAYLAND_DISPLAY', 'CC_KEEPER', 'CC_FRONT_DOOR', 'CC_DEV', 'CC_HOST_TOKEN', 'CC_HOST_SOURCE']) delete out[k]
|
|
510
|
+
for (const k of ['DISPLAY', 'WAYLAND_DISPLAY', 'CC_KEEPER', 'CC_FRONT_DOOR', 'CC_DEV', 'CC_HOST_TOKEN', 'CC_HOST_SOURCE', 'CENTRALU_MANAGED']) delete out[k]
|
|
293
511
|
out.CC_DATA_DIR = dataDir
|
|
512
|
+
// The host `host.stop` may end (`centralu serve --stop`): nothing else stops it in order on Windows
|
|
513
|
+
out.CC_SERVE = '1'
|
|
294
514
|
out.CC_HOST_TOKEN = token
|
|
295
515
|
out.CC_HOST_SOURCE = JSON.stringify({ version, bundlePath: hostDir })
|
|
296
516
|
return out
|
|
@@ -432,9 +652,33 @@ function isAlive(pid) {
|
|
|
432
652
|
}
|
|
433
653
|
}
|
|
434
654
|
|
|
655
|
+
/**
|
|
656
|
+
* Where `say` writes: stderr, except in a detached launcher, which nobody reads the stderr of and
|
|
657
|
+
* which writes serve.log instead (`runDetachedChild`).
|
|
658
|
+
*/
|
|
659
|
+
let sink = (text) => process.stderr.write(text)
|
|
660
|
+
|
|
435
661
|
/** Writes a line to stderr, prefixed so it reads apart from the host's own log lines */
|
|
436
662
|
function say(line) {
|
|
437
|
-
|
|
663
|
+
sink(`[centralu serve] ${line}\n`)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** What `--connection` answers, as fields: the running host's versions when one answers, else the installed ones */
|
|
667
|
+
async function describe({ env, dataDir, state, entry, version }) {
|
|
668
|
+
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
669
|
+
const installed = protocolVersionOf(entry)
|
|
670
|
+
const probe = await probeHost({ port, token: state.token, protocolVersion: installed })
|
|
671
|
+
const protocolVersion = (probe.running ? probe.protocolVersion : undefined) ?? installed
|
|
672
|
+
if (protocolVersion === null) return null
|
|
673
|
+
return {
|
|
674
|
+
port,
|
|
675
|
+
token: state.token,
|
|
676
|
+
version: (probe.running ? probe.version : undefined) ?? version,
|
|
677
|
+
protocolVersion,
|
|
678
|
+
dataDir,
|
|
679
|
+
hostRunning: probe.running,
|
|
680
|
+
install: installInfo(dataDir, env),
|
|
681
|
+
}
|
|
438
682
|
}
|
|
439
683
|
|
|
440
684
|
/**
|
|
@@ -444,40 +688,51 @@ function say(line) {
|
|
|
444
688
|
export async function printConnection({ env, home, entry, version, cliPath }) {
|
|
445
689
|
const dataDir = serveDataDir(env, home)
|
|
446
690
|
const state = ensureServeState(dataDir)
|
|
447
|
-
keepLauncher(dataDir, cliPath)
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
const probe = await probeHost({ port, token: state.token, protocolVersion: installed })
|
|
451
|
-
const protocolVersion = (probe.running ? probe.protocolVersion : undefined) ?? installed
|
|
452
|
-
if (protocolVersion === null) {
|
|
691
|
+
keepLauncher(dataDir, cliPath, env)
|
|
692
|
+
const d = await describe({ env, dataDir, state, entry, version })
|
|
693
|
+
if (!d) {
|
|
453
694
|
say(`cannot read the protocol version of the host at ${entry}`)
|
|
454
695
|
return 1
|
|
455
696
|
}
|
|
456
|
-
process.stdout.write(
|
|
457
|
-
`${connectionLine({
|
|
458
|
-
port,
|
|
459
|
-
token: state.token,
|
|
460
|
-
version: (probe.running ? probe.version : undefined) ?? version,
|
|
461
|
-
protocolVersion,
|
|
462
|
-
dataDir,
|
|
463
|
-
hostRunning: probe.running,
|
|
464
|
-
})}\n`,
|
|
465
|
-
)
|
|
697
|
+
process.stdout.write(`${connectionLine(d)}\n`)
|
|
466
698
|
return 0
|
|
467
699
|
}
|
|
468
700
|
|
|
701
|
+
/** serve.pid: `{ pid, hostPid, port }` of the launcher that runs, or null */
|
|
702
|
+
export function readServePid(dataDir) {
|
|
703
|
+
try {
|
|
704
|
+
const v = JSON.parse(readFileSync(join(dataDir, SERVE_PID_FILE), 'utf8'))
|
|
705
|
+
return Number.isInteger(v?.pid) && v.pid > 0 ? { pid: v.pid, hostPid: Number.isInteger(v.hostPid) ? v.hostPid : null, port: v.port ?? null } : null
|
|
706
|
+
} catch {
|
|
707
|
+
return null
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function removeServePid(dataDir, pid) {
|
|
712
|
+
if (readServePid(dataDir)?.pid !== pid) return
|
|
713
|
+
try {
|
|
714
|
+
rmSync(join(dataDir, SERVE_PID_FILE), { force: true })
|
|
715
|
+
} catch {
|
|
716
|
+
// another serve's by now, or gone
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
469
720
|
/**
|
|
470
721
|
* `centralu serve`. Resolves with the exit code once the host has exited.
|
|
471
722
|
*
|
|
472
723
|
* The host's stdout is read here and never passed on: its ready line carries the token, which is
|
|
473
724
|
* printed nowhere but `--connection`. Every other line the host writes to stdout (a lock conflict,
|
|
474
725
|
* a store from a newer build) it also writes to stderr, which is passed through untouched.
|
|
726
|
+
*
|
|
727
|
+
* `logFd` is serve.log of a detached launcher (`runDetachedChild`): the host's stderr goes there
|
|
728
|
+
* until it is ready, so a host that dies while starting (a native module that does not load, a store
|
|
729
|
+
* from a newer build) says why in a file `--detach` can quote. From then on host.log has every line,
|
|
730
|
+
* and serve.log stops growing.
|
|
475
731
|
*/
|
|
476
|
-
export async function runServe({ env, home, entry, version, cliPath, port: askedPort, platform = process.platform, execPath = process.execPath }) {
|
|
732
|
+
export async function runServe({ env, home, entry, version, cliPath, port: askedPort, platform = process.platform, execPath = process.execPath, logFd = null }) {
|
|
477
733
|
const dataDir = serveDataDir(env, home)
|
|
478
734
|
const state = ensureServeState(dataDir, platform)
|
|
479
|
-
keepLauncher(dataDir, cliPath)
|
|
480
|
-
const port = askedPort ?? state.port ?? DEFAULT_SERVE_PORT
|
|
735
|
+
keepLauncher(dataDir, cliPath, env)
|
|
481
736
|
const protocolVersion = protocolVersionOf(entry)
|
|
482
737
|
|
|
483
738
|
// A serve that is already up answers on the port it recorded; say so before starting a host that
|
|
@@ -490,15 +745,45 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
490
745
|
return 1
|
|
491
746
|
}
|
|
492
747
|
|
|
748
|
+
const onWindows = await windowsListening(env, platform)
|
|
749
|
+
const chosen = await chooseServePort({ asked: askedPort, recorded: state.port, taken: async (p) => onWindows.has(p) || (await listensHere(p)) })
|
|
750
|
+
if (chosen === null) {
|
|
751
|
+
say(`another program listens on 127.0.0.1:${recorded} and on the ${SERVE_PORT_TRIES - 1} ports above it. Give a free one with --port <n>.`)
|
|
752
|
+
return 1
|
|
753
|
+
}
|
|
754
|
+
const port = chosen.port
|
|
755
|
+
if (chosen.from !== null) {
|
|
756
|
+
const who = onWindows.has(chosen.from) ? 'Windows' : 'another program'
|
|
757
|
+
say(`127.0.0.1:${chosen.from} is in use by ${who}; starting on ${port} instead (kept for the next start)`)
|
|
758
|
+
}
|
|
759
|
+
|
|
493
760
|
const plan = hostCommand(execPath, entry, { port, dataDir, platform })
|
|
494
761
|
const child = spawn(plan.command, plan.args, {
|
|
495
|
-
stdio: ['pipe', 'pipe', 'inherit'],
|
|
762
|
+
stdio: ['pipe', 'pipe', logFd === null ? 'inherit' : 'pipe'],
|
|
496
763
|
env: hostEnv(env, { token: state.token, dataDir, version, hostDir: dirname(entry) }),
|
|
497
764
|
detached: plan.detached,
|
|
498
765
|
windowsHide: true,
|
|
499
766
|
})
|
|
500
767
|
|
|
501
768
|
let ready = false
|
|
769
|
+
if (logFd !== null) {
|
|
770
|
+
// Read to the end in any case: a pipe nobody drains stops the host once its buffer is full
|
|
771
|
+
child.stderr.on('data', (b) => {
|
|
772
|
+
if (ready) return
|
|
773
|
+
try {
|
|
774
|
+
writeSync(logFd, b)
|
|
775
|
+
} catch {
|
|
776
|
+
// the log is a convenience
|
|
777
|
+
}
|
|
778
|
+
})
|
|
779
|
+
}
|
|
780
|
+
if (child.pid) {
|
|
781
|
+
try {
|
|
782
|
+
writeFileSync(join(dataDir, SERVE_PID_FILE), `${JSON.stringify({ pid: process.pid, hostPid: child.pid, port })}\n`)
|
|
783
|
+
} catch (e) {
|
|
784
|
+
say(`could not write ${SERVE_PID_FILE}: ${e.message}`)
|
|
785
|
+
}
|
|
786
|
+
}
|
|
502
787
|
createInterface({ input: child.stdout }).on('line', (line) => {
|
|
503
788
|
const p = readyPort(line)
|
|
504
789
|
if (p === null || ready) return
|
|
@@ -509,7 +794,8 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
509
794
|
say(`could not record the port in ${join(dataDir, SERVE_STATE_FILE)}: ${e.message}`)
|
|
510
795
|
}
|
|
511
796
|
say(`listening on 127.0.0.1:${p} (Centralu ${version}, protocol ${protocolVersion ?? 'unknown'}, data ${dataDir})`)
|
|
512
|
-
say(`from your computer: ssh -N -L ${p}:127.0.0.1:${p} <this machine>, or let the app connect over SSH`)
|
|
797
|
+
if (logFd === null) say(`from your computer: ssh -N -L ${p}:127.0.0.1:${p} <this machine>, or let the app connect over SSH`)
|
|
798
|
+
else say(`the host's own log continues in ${join(dataDir, 'host.log')}`)
|
|
513
799
|
})
|
|
514
800
|
|
|
515
801
|
/*
|
|
@@ -532,11 +818,13 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
532
818
|
|
|
533
819
|
return await new Promise((resolve) => {
|
|
534
820
|
child.on('error', (e) => {
|
|
821
|
+
removeServePid(dataDir, process.pid)
|
|
535
822
|
say(`could not start the host (${plan.command}): ${e.message}`)
|
|
536
823
|
resolve(1)
|
|
537
824
|
})
|
|
538
825
|
child.on('exit', (code, signal) => {
|
|
539
826
|
for (const s of signals) process.off(s, forward)
|
|
827
|
+
removeServePid(dataDir, process.pid)
|
|
540
828
|
if (!ready && code !== 0) {
|
|
541
829
|
const pid = lockHolderPid(dataDir)
|
|
542
830
|
if (pid && pid !== child.pid && isAlive(pid)) {
|
|
@@ -546,6 +834,7 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
546
834
|
)
|
|
547
835
|
}
|
|
548
836
|
}
|
|
837
|
+
if (logFd !== null) say(`the host exited (${signal ?? code})`)
|
|
549
838
|
resolve(code ?? (signal ? 1 : 0))
|
|
550
839
|
})
|
|
551
840
|
})
|
|
@@ -559,3 +848,473 @@ export function rotateToken({ env, home }) {
|
|
|
559
848
|
say('A serve that is running keeps the old one until it restarts; clients read the new one with --connection.')
|
|
560
849
|
return 0
|
|
561
850
|
}
|
|
851
|
+
|
|
852
|
+
// ── --detach ────────────────────────────────────────────────────────────────────────────────
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* What a detached launcher needs from the `--detach` that started it. A process created through WMI
|
|
856
|
+
* does not inherit this environment: it gets the account's default one. Everything else it needs
|
|
857
|
+
* (the token, the port) is in the data folder. Never a secret.
|
|
858
|
+
*/
|
|
859
|
+
export const CARRIED_ENV = ['CC_DATA_DIR', 'CENTRALU_HOST_ENTRY', 'CENTRALU_MANAGED']
|
|
860
|
+
|
|
861
|
+
export function encodeChildSpec({ env, log }) {
|
|
862
|
+
const carried = {}
|
|
863
|
+
for (const k of CARRIED_ENV) if (typeof env[k] === 'string' && env[k]) carried[k] = env[k]
|
|
864
|
+
return Buffer.from(JSON.stringify({ log, env: carried }), 'utf8').toString('base64url')
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** `{ log, env }` from `--detached-child=`, only the carried variables; null when it is not one */
|
|
868
|
+
export function decodeChildSpec(raw) {
|
|
869
|
+
try {
|
|
870
|
+
const v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'))
|
|
871
|
+
if (typeof v?.log !== 'string' || !v.log) return null
|
|
872
|
+
const env = {}
|
|
873
|
+
for (const k of CARRIED_ENV) if (typeof v.env?.[k] === 'string') env[k] = v.env[k]
|
|
874
|
+
return { log: v.log, env }
|
|
875
|
+
} catch {
|
|
876
|
+
return null
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* How `--detach` starts the launcher so that it outlives the session that asked (measured,
|
|
882
|
+
* docs/plans/remote-hub.md §10.4):
|
|
883
|
+
*
|
|
884
|
+
* setsid posix: a new session, no terminal, nothing from the ssh session's process group. Once
|
|
885
|
+
* `--detach` returns, the launcher is reparented to init (the second fork of the usual
|
|
886
|
+
* double fork); it opens no terminal, so it can never get a controlling one back
|
|
887
|
+
* wmi Windows: `Win32_Process.Create`. Windows' OpenSSH ends every process of a session with
|
|
888
|
+
* it, `Start-Process` included; a process WMI creates is not part of the session
|
|
889
|
+
* wsl inside WSL: the same WMI call from the Windows side, creating `wsl.exe -d <distro>
|
|
890
|
+
* --exec <launcher>`. A `setsid` launcher inside the distro survives the session but not the
|
|
891
|
+
* distro, which WSL stops about 15 s after its last `wsl.exe` client exits; this
|
|
892
|
+
* `wsl.exe` is a client for as long as the host runs, and ending it ends the host
|
|
893
|
+
*/
|
|
894
|
+
export function detachHow(platform, env, interop = wslInterop) {
|
|
895
|
+
if (platform === 'win32') return 'wmi'
|
|
896
|
+
if (platform === 'linux' && env.WSL_DISTRO_NAME && interop()) return 'wsl'
|
|
897
|
+
return 'setsid'
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function wslInterop() {
|
|
901
|
+
return existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') || existsSync('/proc/sys/fs/binfmt_misc/WSLInterop-late')
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* One argument for a Windows command line, as `CommandLineToArgvW` and the C runtime read it: always
|
|
906
|
+
* quoted, backslashes before the closing quote doubled. A double quote or a line break cannot be a
|
|
907
|
+
* path on Windows and is refused rather than escaped.
|
|
908
|
+
*/
|
|
909
|
+
export function windowsArg(arg) {
|
|
910
|
+
const s = String(arg)
|
|
911
|
+
if (/["\r\n\0]/.test(s)) throw new Error(`cannot pass ${JSON.stringify(s)} on a Windows command line`)
|
|
912
|
+
return `"${s.replace(/(\\+)$/, '$1$1')}"`
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** The PowerShell that asks WMI to create `commandLine`. Values cross as base64, so no quoting can break */
|
|
916
|
+
export function wmiScript({ commandLine, cwd = null }) {
|
|
917
|
+
const text = (s) => `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(s, 'utf8').toString('base64')}'))`
|
|
918
|
+
return [
|
|
919
|
+
"$ErrorActionPreference = 'Stop'",
|
|
920
|
+
'[Console]::OutputEncoding = [Text.Encoding]::UTF8',
|
|
921
|
+
`$a = @{ CommandLine = ${text(commandLine)} }`,
|
|
922
|
+
...(cwd ? [`$a.CurrentDirectory = ${text(cwd)}`] : []),
|
|
923
|
+
// No console window when this runs from a desktop session; left out where it cannot be built
|
|
924
|
+
'try { $a.ProcessStartupInformation = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 } } catch { }',
|
|
925
|
+
'try {',
|
|
926
|
+
' $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $a',
|
|
927
|
+
" if ($r.ReturnValue -eq 21 -and $a.ContainsKey('ProcessStartupInformation')) { $a.Remove('ProcessStartupInformation'); $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $a }",
|
|
928
|
+
' "CENTRALU-WMI $($r.ReturnValue) $($r.ProcessId)"',
|
|
929
|
+
'} catch { "CENTRALU-WMI-ERROR $($_.Exception.Message)" }',
|
|
930
|
+
].join('\n')
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** `Win32_Process.Create`'s return values (Microsoft's documentation of the method) */
|
|
934
|
+
const WMI_CODES = { 2: 'access denied', 3: 'insufficient privilege', 8: 'unknown failure', 9: 'path not found', 21: 'invalid parameter' }
|
|
935
|
+
|
|
936
|
+
/** What the WMI script said: `{ ok: true, pid }` or `{ ok: false, message }` */
|
|
937
|
+
export function parseWmiAnswer(stdout) {
|
|
938
|
+
const text = String(stdout ?? '')
|
|
939
|
+
const m = /CENTRALU-WMI (\d+) (\d*)/.exec(text)
|
|
940
|
+
if (m) {
|
|
941
|
+
const rv = Number(m[1])
|
|
942
|
+
if (rv === 0 && Number(m[2]) > 0) return { ok: true, pid: Number(m[2]) }
|
|
943
|
+
return { ok: false, message: `WMI's Win32_Process.Create returned ${rv}${WMI_CODES[rv] ? ` (${WMI_CODES[rv]})` : ''}` }
|
|
944
|
+
}
|
|
945
|
+
const e = /CENTRALU-WMI-ERROR (.*)/.exec(text)
|
|
946
|
+
if (e) return { ok: false, message: `WMI refused to create the process: ${e[1].trim()}` }
|
|
947
|
+
return { ok: false, message: 'PowerShell did not answer the WMI request' }
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* The sentence for a start WMI did not make. Some managed machines block process creation through
|
|
952
|
+
* WMI (Microsoft Defender's attack surface reduction rule "Block process creations originating from
|
|
953
|
+
* PSExec and WMI commands", off by default); the hub then runs the host bound to its link
|
|
954
|
+
* (docs/plans/remote-hub.md §10.9, decision 3).
|
|
955
|
+
*/
|
|
956
|
+
export function wmiBlockedMessage(detail) {
|
|
957
|
+
return (
|
|
958
|
+
`Windows did not start Centralu through WMI (${detail}). Some managed Windows machines block it ` +
|
|
959
|
+
'(Microsoft Defender\'s attack surface reduction rule "Block process creations originating from PSExec and WMI commands"). ' +
|
|
960
|
+
'Centralu can run there while this computer is linked instead, and stops when the link does.'
|
|
961
|
+
)
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Windows PowerShell 5.1, by full path: under %SystemRoot% on Windows, through the drive WSL mounts inside a distro */
|
|
965
|
+
export function windowsPowershell(how, env, mounts) {
|
|
966
|
+
return windowsTool(how, env, ['System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'], mounts)
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** A program under the Windows folder, by full path, from Windows or from inside a WSL distro; null when unreachable */
|
|
970
|
+
function windowsTool(how, env, rel, mounts = () => readFileSync('/proc/mounts', 'utf8')) {
|
|
971
|
+
if (how === 'wmi') return [env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', ...rel].join('\\')
|
|
972
|
+
let text = ''
|
|
973
|
+
try {
|
|
974
|
+
text = mounts()
|
|
975
|
+
} catch {
|
|
976
|
+
// no /proc: not WSL after all
|
|
977
|
+
}
|
|
978
|
+
for (const line of text.split('\n')) {
|
|
979
|
+
const [, at, type] = line.split(' ')
|
|
980
|
+
if (!at || !(type === '9p' || type === 'drvfs')) continue
|
|
981
|
+
const candidate = [at, 'Windows', ...rel].join('/')
|
|
982
|
+
if (existsSync(candidate)) return candidate
|
|
983
|
+
}
|
|
984
|
+
return null
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function runCaptured(command, args, env) {
|
|
988
|
+
return new Promise((resolve) => {
|
|
989
|
+
let stdout = ''
|
|
990
|
+
let stderr = ''
|
|
991
|
+
let child
|
|
992
|
+
try {
|
|
993
|
+
child = spawn(command, args, { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
994
|
+
} catch (e) {
|
|
995
|
+
return resolve({ code: null, stdout, stderr: e.message })
|
|
996
|
+
}
|
|
997
|
+
child.stdout.on('data', (b) => (stdout += String(b)))
|
|
998
|
+
child.stderr.on('data', (b) => (stderr += String(b)))
|
|
999
|
+
child.once('error', (e) => resolve({ code: null, stdout, stderr: e.message }))
|
|
1000
|
+
child.once('close', (code) => resolve({ code, stdout, stderr }))
|
|
1001
|
+
})
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Starts the launcher outside this session (`detachHow`). `{ ok: true, pid, exited?, alive? }` once
|
|
1006
|
+
* something was created, `{ ok: false, blocked, message }` when nothing was: `blocked` is the WMI
|
|
1007
|
+
* refusal the hub falls back from.
|
|
1008
|
+
*/
|
|
1009
|
+
export async function startDetached({ how, execPath, args, env, cwd }) {
|
|
1010
|
+
if (how === 'setsid') {
|
|
1011
|
+
return await new Promise((resolve) => {
|
|
1012
|
+
let child
|
|
1013
|
+
try {
|
|
1014
|
+
child = spawn(execPath, args, { detached: true, stdio: 'ignore', cwd, env, windowsHide: true })
|
|
1015
|
+
} catch (e) {
|
|
1016
|
+
return resolve({ ok: false, blocked: false, message: `could not start ${execPath}: ${e.message}` })
|
|
1017
|
+
}
|
|
1018
|
+
child.once('error', (e) => resolve({ ok: false, blocked: false, message: `could not start ${execPath}: ${e.message}` }))
|
|
1019
|
+
child.once('spawn', () => {
|
|
1020
|
+
const exited = new Promise((r) => child.once('exit', (code, signal) => r(signal ?? code)))
|
|
1021
|
+
child.unref()
|
|
1022
|
+
resolve({ ok: true, pid: child.pid, exited })
|
|
1023
|
+
})
|
|
1024
|
+
})
|
|
1025
|
+
}
|
|
1026
|
+
const ps = windowsPowershell(how, env)
|
|
1027
|
+
if (!ps) return { ok: false, blocked: true, message: wmiBlockedMessage('Windows PowerShell is not reachable from this WSL distro') }
|
|
1028
|
+
let commandLine
|
|
1029
|
+
try {
|
|
1030
|
+
if (how === 'wmi') commandLine = [execPath, ...args].map(windowsArg).join(' ')
|
|
1031
|
+
else {
|
|
1032
|
+
const distro = env.WSL_DISTRO_NAME ?? ''
|
|
1033
|
+
const user = userInfo().username
|
|
1034
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(distro)) throw new Error(`not a WSL distro name: ${distro}`)
|
|
1035
|
+
if (!/^[A-Za-z_][A-Za-z0-9_.-]{0,31}$/.test(user)) throw new Error(`cannot name the user ${user} to wsl.exe`)
|
|
1036
|
+
commandLine = ['wsl.exe', '-d', distro, '-u', user, '--cd', cwd, '--exec', execPath, ...args].map(windowsArg).join(' ')
|
|
1037
|
+
}
|
|
1038
|
+
} catch (e) {
|
|
1039
|
+
return { ok: false, blocked: false, message: e.message }
|
|
1040
|
+
}
|
|
1041
|
+
const script = wmiScript({ commandLine, cwd: how === 'wmi' ? cwd : null })
|
|
1042
|
+
const r = await runCaptured(ps, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], env)
|
|
1043
|
+
const answer = parseWmiAnswer(r.stdout)
|
|
1044
|
+
if (!answer.ok) return { ok: false, blocked: true, message: wmiBlockedMessage(r.stdout.trim() || !r.stderr.trim() ? answer.message : r.stderr.trim().split(/\r?\n/).at(-1)) }
|
|
1045
|
+
// A Windows pid can be watched from Windows; from inside WSL it names nothing here
|
|
1046
|
+
/*
|
|
1047
|
+
* Measured on a Windows 11 laptop (2026-10-08): a `wsl.exe` that WMI creates can hang without
|
|
1048
|
+
* running anything in the distro (it starts a second wsl.exe and waits), while `wsl.exe --list`
|
|
1049
|
+
* from the same context answers. `--detach` reports that as a start that never ran, and ends what
|
|
1050
|
+
* it created rather than leave it waiting: this pid and its children, nothing else.
|
|
1051
|
+
*/
|
|
1052
|
+
const taskkill = windowsTool(how, env, ['System32', 'taskkill.exe'])
|
|
1053
|
+
const stop = taskkill ? () => runCaptured(taskkill, ['/PID', String(answer.pid), '/T', '/F'], env) : null
|
|
1054
|
+
return { ok: true, pid: answer.pid, alive: how === 'wmi' ? () => isAlive(answer.pid) : null, stop }
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** The last lines of a file, for a reason; empty when it cannot be read */
|
|
1058
|
+
function tail(file, n = 15) {
|
|
1059
|
+
try {
|
|
1060
|
+
return readFileSync(file, 'utf8').trimEnd().split(/\r?\n/).slice(-n).join('\n')
|
|
1061
|
+
} catch {
|
|
1062
|
+
return ''
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* `centralu serve --detach` (docs/plans/remote-hub.md §10.4, S9): starts `serve` outside this
|
|
1070
|
+
* session (`detachHow`) and returns once its host answers a hello, so the caller, typically the
|
|
1071
|
+
* hub over ssh, can connect at once.
|
|
1072
|
+
*
|
|
1073
|
+
* stdout carries one JSON line, read by the hub (`links/tunnel.ts`): the connection line with
|
|
1074
|
+
* `hostRunning: true` and `detach: { ok: true, how, already }`, or `{ v: 1, detach: { ok: false,
|
|
1075
|
+
* how, reason, message } }`. `reason` is `wmi_blocked` when WMI created nothing or created something
|
|
1076
|
+
* that never ran, which the hub answers with a link-bound start; `exited` and `timeout` quote the
|
|
1077
|
+
* end of serve.log. Exit codes do not survive Windows' ssh (agent-host.md §4.8), so the line is
|
|
1078
|
+
* what counts; the exit code is 0 or 1 for a person at a prompt.
|
|
1079
|
+
*
|
|
1080
|
+
* `start` and the two waits are parameters so tests can stand in for WMI.
|
|
1081
|
+
*/
|
|
1082
|
+
export async function runDetach({
|
|
1083
|
+
env,
|
|
1084
|
+
home,
|
|
1085
|
+
entry,
|
|
1086
|
+
version,
|
|
1087
|
+
cliPath,
|
|
1088
|
+
port = null,
|
|
1089
|
+
platform = process.platform,
|
|
1090
|
+
execPath = process.execPath,
|
|
1091
|
+
cwd = process.cwd(),
|
|
1092
|
+
start = startDetached,
|
|
1093
|
+
how = detachHow(platform, env),
|
|
1094
|
+
timeoutMs = 30_000,
|
|
1095
|
+
noStartMs = 10_000,
|
|
1096
|
+
write = (line) => process.stdout.write(line),
|
|
1097
|
+
}) {
|
|
1098
|
+
const dataDir = serveDataDir(env, home)
|
|
1099
|
+
// Created here, so the token exists before the launcher reads it and `answered` reads it again
|
|
1100
|
+
ensureServeState(dataDir, platform)
|
|
1101
|
+
keepLauncher(dataDir, cliPath, env)
|
|
1102
|
+
const fail = (reason, message) => {
|
|
1103
|
+
say(message)
|
|
1104
|
+
write(`${JSON.stringify({ v: 1, detach: { ok: false, how, reason, message } })}\n`)
|
|
1105
|
+
return 1
|
|
1106
|
+
}
|
|
1107
|
+
const answered = async (already) => {
|
|
1108
|
+
const d = await describe({ env, dataDir, state: ensureServeState(dataDir, platform), entry, version })
|
|
1109
|
+
if (!d?.hostRunning) return false
|
|
1110
|
+
write(`${connectionLine({ ...d, detach: { ok: true, how, already } })}\n`)
|
|
1111
|
+
return true
|
|
1112
|
+
}
|
|
1113
|
+
if (await answered(true)) return 0
|
|
1114
|
+
|
|
1115
|
+
const log = join(dataDir, SERVE_LOG_FILE)
|
|
1116
|
+
// One start per log: the last one's is kept beside it, and its absence is how a start that never ran shows
|
|
1117
|
+
try {
|
|
1118
|
+
renameSync(log, `${log}.1`)
|
|
1119
|
+
} catch {
|
|
1120
|
+
// there was none
|
|
1121
|
+
}
|
|
1122
|
+
const args = [cliPath, 'serve', ...(port !== null ? ['--port', String(port)] : []), `${CHILD_FLAG}${encodeChildSpec({ env, log })}`]
|
|
1123
|
+
let started
|
|
1124
|
+
try {
|
|
1125
|
+
started = await start({ how, execPath, args, env, cwd })
|
|
1126
|
+
} catch (e) {
|
|
1127
|
+
return fail('failed', `could not start centralu serve in the background: ${e.message}`)
|
|
1128
|
+
}
|
|
1129
|
+
if (!started.ok) return fail(started.blocked ? 'wmi_blocked' : 'failed', started.message)
|
|
1130
|
+
say(`started centralu serve in the background (${how}, pid ${started.pid ?? 'unknown'}); waiting for its host`)
|
|
1131
|
+
|
|
1132
|
+
let exited
|
|
1133
|
+
started.exited?.then((c) => (exited = c ?? 'unknown'))
|
|
1134
|
+
const t0 = Date.now()
|
|
1135
|
+
while (Date.now() - t0 < timeoutMs) {
|
|
1136
|
+
if (await answered(false)) return 0
|
|
1137
|
+
const why = tail(log)
|
|
1138
|
+
if (exited !== undefined) return fail('exited', `centralu serve exited (${exited}) before its host answered${why ? `:\n${why}` : ''}`)
|
|
1139
|
+
const gone = started.alive?.() === false
|
|
1140
|
+
if (how !== 'setsid' && !existsSync(log) && (gone || Date.now() - t0 > noStartMs)) {
|
|
1141
|
+
// WMI said yes, and nothing ran: the process was stopped before Node started, or hangs (`startDetached`)
|
|
1142
|
+
if (!gone) await started.stop?.()
|
|
1143
|
+
return fail('wmi_blocked', wmiBlockedMessage(gone ? `process ${started.pid} ended before it started` : `nothing started within ${Math.round(noStartMs / 1000)} s`))
|
|
1144
|
+
}
|
|
1145
|
+
if (gone) return fail('exited', `centralu serve exited before its host answered${why ? `:\n${why}` : ''}`)
|
|
1146
|
+
await sleep(250)
|
|
1147
|
+
}
|
|
1148
|
+
const why = tail(log)
|
|
1149
|
+
return fail('timeout', `the host did not answer within ${Math.round(timeoutMs / 1000)} s${why ? `:\n${why}` : ''}`)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* The launcher `--detach` started: `runServe` writing to serve.log. The carried variables are
|
|
1154
|
+
* already in `env` (centralu.mjs merges them before it looks for the host).
|
|
1155
|
+
*/
|
|
1156
|
+
export async function runDetachedChild({ spec, ...opts }) {
|
|
1157
|
+
let fd = null
|
|
1158
|
+
try {
|
|
1159
|
+
fd = openSync(spec.log, 'a')
|
|
1160
|
+
sink = (text) => {
|
|
1161
|
+
try {
|
|
1162
|
+
writeSync(fd, text)
|
|
1163
|
+
} catch {
|
|
1164
|
+
// nowhere left to say it
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
} catch {
|
|
1168
|
+
// no log: say() keeps stderr, which nobody reads; --detach then reports the start as never run
|
|
1169
|
+
}
|
|
1170
|
+
say(`started in the background (pid ${process.pid}, Node ${process.version})`)
|
|
1171
|
+
try {
|
|
1172
|
+
return await runServe({ ...opts, logFd: fd })
|
|
1173
|
+
} catch (e) {
|
|
1174
|
+
say(e?.message ?? String(e))
|
|
1175
|
+
return 1
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// ── --stop ──────────────────────────────────────────────────────────────────────────────────
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Asks a running host to stop (`host.stop`, an additive RPC): a hello with the token, then the call.
|
|
1183
|
+
* `{ ok: true }` once the host took it, `{ ok: false, message, protocolVersion? }` otherwise; the
|
|
1184
|
+
* last names the host's protocol when the hello was refused for it, so the caller can ask again.
|
|
1185
|
+
*/
|
|
1186
|
+
export function askHostToStop({ port, token, protocolVersion, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
|
|
1187
|
+
if (typeof WebSocketImpl !== 'function') return Promise.resolve({ ok: false, message: `Node ${process.version} has no WebSocket to ask with` })
|
|
1188
|
+
return new Promise((resolve) => {
|
|
1189
|
+
let ws = null
|
|
1190
|
+
let settled = false
|
|
1191
|
+
const done = (result) => {
|
|
1192
|
+
if (settled) return
|
|
1193
|
+
settled = true
|
|
1194
|
+
clearTimeout(timer)
|
|
1195
|
+
try {
|
|
1196
|
+
ws?.close()
|
|
1197
|
+
} catch {
|
|
1198
|
+
// already closed
|
|
1199
|
+
}
|
|
1200
|
+
resolve(result)
|
|
1201
|
+
}
|
|
1202
|
+
const timer = setTimeout(() => done({ ok: false, message: 'no answer in time' }), timeoutMs)
|
|
1203
|
+
try {
|
|
1204
|
+
ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
|
|
1205
|
+
} catch (e) {
|
|
1206
|
+
done({ ok: false, message: e.message })
|
|
1207
|
+
return
|
|
1208
|
+
}
|
|
1209
|
+
ws.onopen = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
|
|
1210
|
+
ws.onmessage = (ev) => {
|
|
1211
|
+
let m
|
|
1212
|
+
try {
|
|
1213
|
+
m = JSON.parse(String(ev.data))
|
|
1214
|
+
} catch {
|
|
1215
|
+
return
|
|
1216
|
+
}
|
|
1217
|
+
if (m?.kind === 'hello_ok') ws.send(JSON.stringify({ kind: 'rpc', id: 'stop', method: 'host.stop', params: {} }))
|
|
1218
|
+
else if (m?.kind === 'res' && m.id === 'stop') done(m.ok ? { ok: true } : { ok: false, message: m.error?.message ?? 'refused' })
|
|
1219
|
+
else if (m?.kind === 'res' && m.ok === false) {
|
|
1220
|
+
const pv = m.error?.code === 'version_mismatch' ? (m.error?.data?.protocolVersion ?? mismatchServerVersion(m.error.message)) : null
|
|
1221
|
+
done({ ok: false, message: m.error?.message ?? 'refused', ...(Number.isInteger(pv) ? { protocolVersion: pv } : {}) })
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
ws.onerror = () => done({ ok: false, message: 'could not connect' })
|
|
1225
|
+
ws.onclose = () => done({ ok: false, message: 'the connection closed' })
|
|
1226
|
+
})
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
async function waitUntil(pred, ms) {
|
|
1230
|
+
const t0 = Date.now()
|
|
1231
|
+
while (Date.now() - t0 < ms) {
|
|
1232
|
+
if (await pred()) return true
|
|
1233
|
+
await sleep(200)
|
|
1234
|
+
}
|
|
1235
|
+
return pred()
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* `centralu serve --stop` (docs/plans/remote-hub.md §10.4, S10): asks the running host to stop over
|
|
1240
|
+
* its own socket and waits until it has, so its shutdown runs (its agents, terminals and app
|
|
1241
|
+
* processes stopped in order, the store closed). Measured on Windows: killing the WMI-started
|
|
1242
|
+
* launcher ended the host 143 ms later with no "shutting down" line, because the host sits in
|
|
1243
|
+
* libuv's kill-on-close job object; so on Windows killing is the last resort, not the way.
|
|
1244
|
+
*
|
|
1245
|
+
* Then, only for a host that did not go: posix SIGTERM to the launcher (which passes it to the host,
|
|
1246
|
+
* the same clean stop), and on Windows ending the host. A host from before `host.stop` gets that
|
|
1247
|
+
* too. The launcher ends with its host; one still there afterwards is ended.
|
|
1248
|
+
*
|
|
1249
|
+
* Only the processes serve.pid and host.lock name, and only while they agree with each other and
|
|
1250
|
+
* the host answered with this folder's token: a stale pid is never signalled.
|
|
1251
|
+
*
|
|
1252
|
+
* stdout: one line, `{ v: 1, stop: { ok, wasRunning, how?, message? } }`.
|
|
1253
|
+
*/
|
|
1254
|
+
export async function runStop({
|
|
1255
|
+
env,
|
|
1256
|
+
home,
|
|
1257
|
+
entry,
|
|
1258
|
+
platform = process.platform,
|
|
1259
|
+
timeoutMs = 30_000,
|
|
1260
|
+
graceMs = 15_000,
|
|
1261
|
+
kill = (pid, sig) => process.kill(pid, sig),
|
|
1262
|
+
write = (line) => process.stdout.write(line),
|
|
1263
|
+
}) {
|
|
1264
|
+
const dataDir = serveDataDir(env, home)
|
|
1265
|
+
const report = (stop) => {
|
|
1266
|
+
if (stop.message) say(stop.message)
|
|
1267
|
+
write(`${JSON.stringify({ v: 1, stop })}\n`)
|
|
1268
|
+
return stop.ok ? 0 : 1
|
|
1269
|
+
}
|
|
1270
|
+
const state = readServeState(dataDir)
|
|
1271
|
+
if (!state) return report({ ok: true, wasRunning: false, message: `no centralu serve has run for ${dataDir}` })
|
|
1272
|
+
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
1273
|
+
const installed = protocolVersionOf(entry) ?? 0
|
|
1274
|
+
const probe = () => probeHost({ port, token: state.token, protocolVersion: installed })
|
|
1275
|
+
const first = await probe()
|
|
1276
|
+
if (!first.running) return report({ ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}` })
|
|
1277
|
+
|
|
1278
|
+
const hostPid = lockHolderPid(dataDir)
|
|
1279
|
+
const launcher = readServePid(dataDir)
|
|
1280
|
+
// serve.pid is only believed when it names the host that holds the folder now
|
|
1281
|
+
const launcherPid = launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
|
|
1282
|
+
const down = async () => !(await probe()).running && !(hostPid && isAlive(hostPid))
|
|
1283
|
+
|
|
1284
|
+
let asked = await askHostToStop({ port, token: state.token, protocolVersion: first.protocolVersion ?? installed })
|
|
1285
|
+
if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, token: state.token, protocolVersion: asked.protocolVersion })
|
|
1286
|
+
let how = 'asked'
|
|
1287
|
+
let stopped = asked.ok && (await waitUntil(down, timeoutMs))
|
|
1288
|
+
let note = asked.ok ? null : `the host did not take the request to stop (${asked.message})`
|
|
1289
|
+
if (!stopped) {
|
|
1290
|
+
const target = platform !== 'win32' && launcherPid ? launcherPid : hostPid
|
|
1291
|
+
if (target) {
|
|
1292
|
+
how = platform === 'win32' ? 'ended' : 'signal'
|
|
1293
|
+
try {
|
|
1294
|
+
kill(target, 'SIGTERM')
|
|
1295
|
+
} catch {
|
|
1296
|
+
// gone in the meantime
|
|
1297
|
+
}
|
|
1298
|
+
stopped = await waitUntil(down, platform === 'win32' ? 5_000 : graceMs)
|
|
1299
|
+
if (!stopped && hostPid && isAlive(hostPid)) {
|
|
1300
|
+
how = 'ended'
|
|
1301
|
+
try {
|
|
1302
|
+
kill(hostPid, 'SIGKILL')
|
|
1303
|
+
} catch {
|
|
1304
|
+
// gone in the meantime
|
|
1305
|
+
}
|
|
1306
|
+
stopped = await waitUntil(down, 5_000)
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
if (how === 'ended') note = `${note ? `${note}; ` : ''}ended the host without its shutdown`
|
|
1310
|
+
}
|
|
1311
|
+
if (launcherPid && launcherPid !== process.pid && !(await waitUntil(() => !isAlive(launcherPid), 5_000))) {
|
|
1312
|
+
try {
|
|
1313
|
+
kill(launcherPid, 'SIGKILL')
|
|
1314
|
+
} catch {
|
|
1315
|
+
// gone in the meantime
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
if (!stopped) return report({ ok: false, wasRunning: true, how, message: `the host on 127.0.0.1:${port} is still running${note ? ` (${note})` : ''}` })
|
|
1319
|
+
return report({ ok: true, wasRunning: true, how, ...(note ? { message: note } : {}) })
|
|
1320
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "centralu",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.14",
|
|
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",
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"win32"
|
|
38
38
|
],
|
|
39
39
|
"optionalDependencies": {
|
|
40
|
-
"@centralu/darwin-arm64": "0.1.0-beta.
|
|
41
|
-
"@centralu/linux-arm64": "0.1.0-beta.
|
|
42
|
-
"@centralu/linux-x64": "0.1.0-beta.
|
|
43
|
-
"@centralu/win32-x64": "0.1.0-beta.
|
|
40
|
+
"@centralu/darwin-arm64": "0.1.0-beta.14",
|
|
41
|
+
"@centralu/linux-arm64": "0.1.0-beta.14",
|
|
42
|
+
"@centralu/linux-x64": "0.1.0-beta.14",
|
|
43
|
+
"@centralu/win32-x64": "0.1.0-beta.14"
|
|
44
44
|
}
|
|
45
45
|
}
|