centralu 0.1.0-beta.11 → 0.1.0-beta.13
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 +726 -42
- 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'
|
|
20
|
+
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync, closeSync } from 'node:fs'
|
|
21
21
|
import { connect } 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
|
|
|
@@ -43,36 +44,62 @@ export const DEFAULT_SERVE_PORT = 17175
|
|
|
43
44
|
*/
|
|
44
45
|
export const SERVE_STATE_FILE = 'serve.json'
|
|
45
46
|
|
|
47
|
+
/** What a detached serve writes: its own lines, and the host's until it is ready (host.log has the rest) */
|
|
48
|
+
export const SERVE_LOG_FILE = 'serve.log'
|
|
49
|
+
|
|
50
|
+
/** The running launcher's pid and port, so `--stop` can find it. Removed when it exits */
|
|
51
|
+
export const SERVE_PID_FILE = 'serve.pid'
|
|
52
|
+
|
|
46
53
|
export const SERVE_HELP = `centralu serve: run the Centralu host on this machine, without a window
|
|
47
54
|
|
|
48
55
|
centralu serve Start the host in the foreground, on 127.0.0.1 only
|
|
49
56
|
centralu serve --port <n> ... on that port (kept for the next start; default ${DEFAULT_SERVE_PORT})
|
|
57
|
+
centralu serve --detach Start it in the background, outside this session; return once it answers
|
|
58
|
+
centralu serve --stop Ask the running host to stop, and wait until it has
|
|
50
59
|
centralu serve --connection Print how to reach it as one JSON line, then exit
|
|
51
60
|
centralu serve --rotate-token Replace the token (a running serve keeps the old one until restarted)
|
|
52
61
|
|
|
53
62
|
The data folder is ~/.centralu (CC_DATA_DIR overrides it). The app on another computer reaches
|
|
54
|
-
this host through an SSH local forward
|
|
63
|
+
this host through an SSH local forward, and starts it with --detach when it is not running.
|
|
64
|
+
Stop a foreground one with Ctrl+C or SIGTERM, a detached one with --stop. A detached one writes
|
|
65
|
+
how its start went to serve.log in the data folder; the host's own log is host.log.
|
|
66
|
+
|
|
67
|
+
serve, --detach and --connection keep ~/.centralu/bin/centralu up to date: a launcher with absolute
|
|
68
|
+
paths, for SSH sessions whose PATH has neither npm's global folder nor node. Not when they run
|
|
69
|
+
from an install the app made over SSH, which has a launcher of its own.`
|
|
70
|
+
|
|
71
|
+
/** The flags that each make serve a different command */
|
|
72
|
+
const MODE_FLAGS = { '--connection': 'connection', '--rotate-token': 'rotate', '--detach': 'detach', '--stop': 'stop' }
|
|
73
|
+
const FLAG_OF = Object.fromEntries(Object.entries(MODE_FLAGS).map(([f, m]) => [m, f]))
|
|
55
74
|
|
|
56
|
-
|
|
57
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The argument a detached launcher is started with: what it needs from the `--detach` that started
|
|
77
|
+
* it, which a process created through WMI does not inherit (it gets the account's default
|
|
78
|
+
* environment, not this one). Never a secret: the token stays in serve.json. Internal, not in the help.
|
|
79
|
+
*/
|
|
80
|
+
const CHILD_FLAG = '--detached-child='
|
|
58
81
|
|
|
59
82
|
/**
|
|
60
|
-
* What the arguments after `serve` ask for: `{ mode, port }`
|
|
83
|
+
* What the arguments after `serve` ask for: `{ mode, port }` (plus `child` for a detached launcher)
|
|
84
|
+
* or `{ error }`.
|
|
61
85
|
*
|
|
62
|
-
* `mode` is `serve`, `connection`, `rotate` or `help`; `port` is null when not
|
|
63
|
-
*
|
|
86
|
+
* `mode` is `serve`, `detach`, `stop`, `connection`, `rotate` or `help`; `port` is null when not
|
|
87
|
+
* given. `--port` goes with `serve` and `--detach` only: `--connection` has to report the port
|
|
88
|
+
* `serve` uses, not one the caller names, and `--stop` stops the one that runs.
|
|
64
89
|
*/
|
|
65
90
|
export function parseServeArgs(argv) {
|
|
66
91
|
let mode = 'serve'
|
|
67
92
|
let port = null
|
|
93
|
+
let help = false
|
|
94
|
+
let child = null
|
|
68
95
|
for (let i = 0; i < argv.length; i++) {
|
|
69
96
|
const a = argv[i]
|
|
70
|
-
if (
|
|
71
|
-
const m = a
|
|
72
|
-
if (mode !== 'serve' && mode !==
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
else if (a
|
|
97
|
+
if (Object.hasOwn(MODE_FLAGS, a)) {
|
|
98
|
+
const m = MODE_FLAGS[a]
|
|
99
|
+
if (mode !== 'serve' && mode !== m) return { error: `${FLAG_OF[mode]} and ${a} are separate commands; pass one` }
|
|
100
|
+
mode = m
|
|
101
|
+
} else if (a === '--help' || a === '-h') help = true
|
|
102
|
+
else if (a.startsWith(CHILD_FLAG)) child = a.slice(CHILD_FLAG.length)
|
|
76
103
|
else if (a === '--port' || a.startsWith('--port=')) {
|
|
77
104
|
const raw = a === '--port' ? argv[++i] : a.slice('--port='.length)
|
|
78
105
|
const n = parsePort(raw)
|
|
@@ -80,9 +107,15 @@ export function parseServeArgs(argv) {
|
|
|
80
107
|
port = n
|
|
81
108
|
} else return { error: `unknown option for serve: ${a}\n\n${SERVE_HELP}` }
|
|
82
109
|
}
|
|
110
|
+
if (help) return { mode: 'help', port }
|
|
111
|
+
if (child !== null) {
|
|
112
|
+
if (mode !== 'serve') return { error: `${CHILD_FLAG.slice(0, -1)} is how --detach starts serve, not an option of ${FLAG_OF[mode]}` }
|
|
113
|
+
return { mode, port, child }
|
|
114
|
+
}
|
|
83
115
|
if ((mode === 'connection' || mode === 'rotate') && port !== null) {
|
|
84
116
|
return { error: '--port goes with `centralu serve`; `--connection` reports the port serve listens on' }
|
|
85
117
|
}
|
|
118
|
+
if (mode === 'stop' && port !== null) return { error: '--stop stops the serve that is running, on the port it recorded; it takes no --port' }
|
|
86
119
|
return { mode, port }
|
|
87
120
|
}
|
|
88
121
|
|
|
@@ -214,9 +247,16 @@ export function ensureLauncher(dataDir, { platform = process.platform, execPath
|
|
|
214
247
|
return file
|
|
215
248
|
}
|
|
216
249
|
|
|
217
|
-
/**
|
|
218
|
-
|
|
219
|
-
|
|
250
|
+
/**
|
|
251
|
+
* ensureLauncher, with a failure said on stderr instead of thrown: the launcher is a convenience.
|
|
252
|
+
*
|
|
253
|
+
* Not under the managed launcher (`CENTRALU_MANAGED=1`, docs/plans/remote-hub.md §10.1): an install
|
|
254
|
+
* the hub made over ssh has its own launcher in `<data>/remote/bin/`, which reads `current`. Writing
|
|
255
|
+
* `<data>/bin/centralu` from there would point an npm install's launcher at one managed version, and
|
|
256
|
+
* the two would rewrite each other's file on every start.
|
|
257
|
+
*/
|
|
258
|
+
function keepLauncher(dataDir, cliPath, env) {
|
|
259
|
+
if (!cliPath || env?.CENTRALU_MANAGED === '1') return
|
|
220
260
|
try {
|
|
221
261
|
ensureLauncher(dataDir, { cliPath })
|
|
222
262
|
} catch (e) {
|
|
@@ -269,9 +309,121 @@ export function protocolVersionOf(entry) {
|
|
|
269
309
|
return null
|
|
270
310
|
}
|
|
271
311
|
|
|
272
|
-
/**
|
|
273
|
-
|
|
274
|
-
|
|
312
|
+
/**
|
|
313
|
+
* The `--connection` answer. The shape is the client's interface (docs/agent-host.md §4.7).
|
|
314
|
+
* `install` (additive, the line stays `v: 1`) says what runs this command (`installInfo`); `detach`
|
|
315
|
+
* is added by `--detach` only.
|
|
316
|
+
*/
|
|
317
|
+
export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, detach }) {
|
|
318
|
+
return JSON.stringify({
|
|
319
|
+
v: 1,
|
|
320
|
+
port,
|
|
321
|
+
token,
|
|
322
|
+
version,
|
|
323
|
+
protocolVersion,
|
|
324
|
+
dataDir,
|
|
325
|
+
hostRunning,
|
|
326
|
+
...(install ? { install } : {}),
|
|
327
|
+
...(detach ? { detach } : {}),
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The install the hub manages on this machine (docs/plans/remote-hub.md §10.1): side-by-side
|
|
333
|
+
* versions, the pinned Node they run on, and two pointer files naming what runs and what to roll
|
|
334
|
+
* back to. The hub's installer writes it; `serve` only reads it.
|
|
335
|
+
*/
|
|
336
|
+
export function remoteLayout(dataDir) {
|
|
337
|
+
const root = join(dataDir, 'remote')
|
|
338
|
+
return {
|
|
339
|
+
root,
|
|
340
|
+
current: join(root, 'current'),
|
|
341
|
+
previous: join(root, 'previous'),
|
|
342
|
+
versions: join(root, 'versions'),
|
|
343
|
+
node: join(root, 'node'),
|
|
344
|
+
bin: join(root, 'bin'),
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** A word a pointer file may hold: a folder name, nothing a path could escape with */
|
|
349
|
+
const POINTER_WORD = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/
|
|
350
|
+
|
|
351
|
+
/** `current` / `previous`: one line, "<centralu version> <node version>". null for anything else */
|
|
352
|
+
export function parsePointer(text) {
|
|
353
|
+
const m = /^\s*(\S+)[ \t]+(\S+)\s*$/.exec(String(text ?? ''))
|
|
354
|
+
if (!m || !POINTER_WORD.test(m[1]) || !POINTER_WORD.test(m[2])) return null
|
|
355
|
+
return { version: m[1], node: m[2] }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The connection line's `install` field (docs/plans/remote-hub.md §10.5): whether this command was
|
|
360
|
+
* started by the managed launcher, the managed install's `current` and `previous`, and the Node
|
|
361
|
+
* running this command. Read from the machine at every ask, never kept by the hub (S12).
|
|
362
|
+
*/
|
|
363
|
+
export function installInfo(dataDir, env, nodeVersion = process.versions.node) {
|
|
364
|
+
const l = remoteLayout(dataDir)
|
|
365
|
+
const read = (file) => {
|
|
366
|
+
try {
|
|
367
|
+
return parsePointer(readFileSync(file, 'utf8'))
|
|
368
|
+
} catch {
|
|
369
|
+
return null
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return { managed: env.CENTRALU_MANAGED === '1', current: read(l.current), previous: read(l.previous), node: nodeVersion }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The managed launcher, `<data>/remote/bin/centralu` (`centralu.cmd` on Windows), as the installer
|
|
377
|
+
* writes it (docs/plans/remote-hub.md §10.1, S8).
|
|
378
|
+
*
|
|
379
|
+
* **Its text never changes.** It reads `current` at every start and runs that version's
|
|
380
|
+
* `centralu.mjs` on that version's Node, so switching versions is one file replaced by a rename,
|
|
381
|
+
* atomic on both systems. A launcher rewritten per version would be replaced while it may be
|
|
382
|
+
* running, and cmd.exe reads a batch file by offset as it runs. It sets `CENTRALU_MANAGED=1`, so
|
|
383
|
+
* the `serve` it starts leaves `<data>/bin/` to an npm install on the same machine.
|
|
384
|
+
*
|
|
385
|
+
* Both refuse a pointer that holds anything but plain version words, so an edited `current` cannot
|
|
386
|
+
* point it outside the install.
|
|
387
|
+
*/
|
|
388
|
+
export function managedLauncherScript(platform) {
|
|
389
|
+
if (platform === 'win32') {
|
|
390
|
+
return (
|
|
391
|
+
[
|
|
392
|
+
'@echo off',
|
|
393
|
+
'rem The Centralu launcher for the install the app manages over SSH. It never changes: it reads',
|
|
394
|
+
'rem "current" at every start, so switching versions replaces that file and never this one.',
|
|
395
|
+
'setlocal',
|
|
396
|
+
'set "CENTRALU_REMOTE=%~dp0.."',
|
|
397
|
+
'set "CENTRALU_LINE="',
|
|
398
|
+
'set "CENTRALU_V="',
|
|
399
|
+
'set "CENTRALU_N="',
|
|
400
|
+
'set /p CENTRALU_LINE=<"%CENTRALU_REMOTE%\\current"',
|
|
401
|
+
'for /f "tokens=1,2" %%a in ("%CENTRALU_LINE%") do (set "CENTRALU_V=%%a" & set "CENTRALU_N=%%b")',
|
|
402
|
+
'if not defined CENTRALU_N goto none',
|
|
403
|
+
'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',
|
|
404
|
+
'set CENTRALU_MANAGED=1',
|
|
405
|
+
'"%CENTRALU_REMOTE%\\node\\v%CENTRALU_N%\\node.exe" "%CENTRALU_REMOTE%\\versions\\%CENTRALU_V%\\node_modules\\centralu\\bin\\centralu.mjs" %*',
|
|
406
|
+
'exit /b %ERRORLEVEL%',
|
|
407
|
+
':none',
|
|
408
|
+
'echo Centralu: no version is installed in %CENTRALU_REMOTE% 1>&2',
|
|
409
|
+
'exit /b 1',
|
|
410
|
+
].join('\r\n') + '\r\n'
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
return [
|
|
414
|
+
'#!/bin/sh',
|
|
415
|
+
'# The Centralu launcher for the install the app manages over SSH. It never changes: it reads',
|
|
416
|
+
'# `current` at every start, so switching versions replaces that file and never this one.',
|
|
417
|
+
'remote=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) || exit 1',
|
|
418
|
+
'v= n=',
|
|
419
|
+
'read -r v n < "$remote/current" 2>/dev/null',
|
|
420
|
+
'case "$v" in ""|[!0-9A-Za-z]*|*[!0-9A-Za-z.+-]*) echo "Centralu: no version is installed in $remote" >&2; exit 1 ;; esac',
|
|
421
|
+
'case "$n" in ""|[!0-9A-Za-z]*|*[!0-9A-Za-z.+-]*) echo "Centralu: no version is installed in $remote" >&2; exit 1 ;; esac',
|
|
422
|
+
'CENTRALU_MANAGED=1',
|
|
423
|
+
'export CENTRALU_MANAGED',
|
|
424
|
+
'exec "$remote/node/v$n/bin/node" "$remote/versions/$v/node_modules/centralu/bin/centralu.mjs" "$@"',
|
|
425
|
+
'',
|
|
426
|
+
].join('\n')
|
|
275
427
|
}
|
|
276
428
|
|
|
277
429
|
/**
|
|
@@ -285,12 +437,16 @@ export function connectionLine({ port, token, version, protocolVersion, dataDir,
|
|
|
285
437
|
* - Headless: no `DISPLAY` / `WAYLAND_DISPLAY`. Nothing the host or an agent starts gets a screen
|
|
286
438
|
* to open a window or a keyring prompt on, so a tool that would ask falls back to its file store
|
|
287
439
|
* 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.
|
|
440
|
+
* - The keeper's and dev mode's variables are dropped: this host is neither. So is
|
|
441
|
+
* `CENTRALU_MANAGED`, which is the launcher's business and would otherwise reach every agent.
|
|
442
|
+
* - `CC_SERVE=1` lets the host take `host.stop` (`--stop`); a host the app runs refuses it.
|
|
289
443
|
*/
|
|
290
444
|
export function hostEnv(env, { token, dataDir, version, hostDir }) {
|
|
291
445
|
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]
|
|
446
|
+
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
447
|
out.CC_DATA_DIR = dataDir
|
|
448
|
+
// The host `host.stop` may end (`centralu serve --stop`): nothing else stops it in order on Windows
|
|
449
|
+
out.CC_SERVE = '1'
|
|
294
450
|
out.CC_HOST_TOKEN = token
|
|
295
451
|
out.CC_HOST_SOURCE = JSON.stringify({ version, bundlePath: hostDir })
|
|
296
452
|
return out
|
|
@@ -432,9 +588,33 @@ function isAlive(pid) {
|
|
|
432
588
|
}
|
|
433
589
|
}
|
|
434
590
|
|
|
591
|
+
/**
|
|
592
|
+
* Where `say` writes: stderr, except in a detached launcher, which nobody reads the stderr of and
|
|
593
|
+
* which writes serve.log instead (`runDetachedChild`).
|
|
594
|
+
*/
|
|
595
|
+
let sink = (text) => process.stderr.write(text)
|
|
596
|
+
|
|
435
597
|
/** Writes a line to stderr, prefixed so it reads apart from the host's own log lines */
|
|
436
598
|
function say(line) {
|
|
437
|
-
|
|
599
|
+
sink(`[centralu serve] ${line}\n`)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** What `--connection` answers, as fields: the running host's versions when one answers, else the installed ones */
|
|
603
|
+
async function describe({ env, dataDir, state, entry, version }) {
|
|
604
|
+
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
605
|
+
const installed = protocolVersionOf(entry)
|
|
606
|
+
const probe = await probeHost({ port, token: state.token, protocolVersion: installed })
|
|
607
|
+
const protocolVersion = (probe.running ? probe.protocolVersion : undefined) ?? installed
|
|
608
|
+
if (protocolVersion === null) return null
|
|
609
|
+
return {
|
|
610
|
+
port,
|
|
611
|
+
token: state.token,
|
|
612
|
+
version: (probe.running ? probe.version : undefined) ?? version,
|
|
613
|
+
protocolVersion,
|
|
614
|
+
dataDir,
|
|
615
|
+
hostRunning: probe.running,
|
|
616
|
+
install: installInfo(dataDir, env),
|
|
617
|
+
}
|
|
438
618
|
}
|
|
439
619
|
|
|
440
620
|
/**
|
|
@@ -444,39 +624,51 @@ function say(line) {
|
|
|
444
624
|
export async function printConnection({ env, home, entry, version, cliPath }) {
|
|
445
625
|
const dataDir = serveDataDir(env, home)
|
|
446
626
|
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) {
|
|
627
|
+
keepLauncher(dataDir, cliPath, env)
|
|
628
|
+
const d = await describe({ env, dataDir, state, entry, version })
|
|
629
|
+
if (!d) {
|
|
453
630
|
say(`cannot read the protocol version of the host at ${entry}`)
|
|
454
631
|
return 1
|
|
455
632
|
}
|
|
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
|
-
)
|
|
633
|
+
process.stdout.write(`${connectionLine(d)}\n`)
|
|
466
634
|
return 0
|
|
467
635
|
}
|
|
468
636
|
|
|
637
|
+
/** serve.pid: `{ pid, hostPid, port }` of the launcher that runs, or null */
|
|
638
|
+
export function readServePid(dataDir) {
|
|
639
|
+
try {
|
|
640
|
+
const v = JSON.parse(readFileSync(join(dataDir, SERVE_PID_FILE), 'utf8'))
|
|
641
|
+
return Number.isInteger(v?.pid) && v.pid > 0 ? { pid: v.pid, hostPid: Number.isInteger(v.hostPid) ? v.hostPid : null, port: v.port ?? null } : null
|
|
642
|
+
} catch {
|
|
643
|
+
return null
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function removeServePid(dataDir, pid) {
|
|
648
|
+
if (readServePid(dataDir)?.pid !== pid) return
|
|
649
|
+
try {
|
|
650
|
+
rmSync(join(dataDir, SERVE_PID_FILE), { force: true })
|
|
651
|
+
} catch {
|
|
652
|
+
// another serve's by now, or gone
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
469
656
|
/**
|
|
470
657
|
* `centralu serve`. Resolves with the exit code once the host has exited.
|
|
471
658
|
*
|
|
472
659
|
* The host's stdout is read here and never passed on: its ready line carries the token, which is
|
|
473
660
|
* printed nowhere but `--connection`. Every other line the host writes to stdout (a lock conflict,
|
|
474
661
|
* a store from a newer build) it also writes to stderr, which is passed through untouched.
|
|
662
|
+
*
|
|
663
|
+
* `logFd` is serve.log of a detached launcher (`runDetachedChild`): the host's stderr goes there
|
|
664
|
+
* until it is ready, so a host that dies while starting (a native module that does not load, a store
|
|
665
|
+
* from a newer build) says why in a file `--detach` can quote. From then on host.log has every line,
|
|
666
|
+
* and serve.log stops growing.
|
|
475
667
|
*/
|
|
476
|
-
export async function runServe({ env, home, entry, version, cliPath, port: askedPort, platform = process.platform, execPath = process.execPath }) {
|
|
668
|
+
export async function runServe({ env, home, entry, version, cliPath, port: askedPort, platform = process.platform, execPath = process.execPath, logFd = null }) {
|
|
477
669
|
const dataDir = serveDataDir(env, home)
|
|
478
670
|
const state = ensureServeState(dataDir, platform)
|
|
479
|
-
keepLauncher(dataDir, cliPath)
|
|
671
|
+
keepLauncher(dataDir, cliPath, env)
|
|
480
672
|
const port = askedPort ?? state.port ?? DEFAULT_SERVE_PORT
|
|
481
673
|
const protocolVersion = protocolVersionOf(entry)
|
|
482
674
|
|
|
@@ -492,13 +684,31 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
492
684
|
|
|
493
685
|
const plan = hostCommand(execPath, entry, { port, dataDir, platform })
|
|
494
686
|
const child = spawn(plan.command, plan.args, {
|
|
495
|
-
stdio: ['pipe', 'pipe', 'inherit'],
|
|
687
|
+
stdio: ['pipe', 'pipe', logFd === null ? 'inherit' : 'pipe'],
|
|
496
688
|
env: hostEnv(env, { token: state.token, dataDir, version, hostDir: dirname(entry) }),
|
|
497
689
|
detached: plan.detached,
|
|
498
690
|
windowsHide: true,
|
|
499
691
|
})
|
|
500
692
|
|
|
501
693
|
let ready = false
|
|
694
|
+
if (logFd !== null) {
|
|
695
|
+
// Read to the end in any case: a pipe nobody drains stops the host once its buffer is full
|
|
696
|
+
child.stderr.on('data', (b) => {
|
|
697
|
+
if (ready) return
|
|
698
|
+
try {
|
|
699
|
+
writeSync(logFd, b)
|
|
700
|
+
} catch {
|
|
701
|
+
// the log is a convenience
|
|
702
|
+
}
|
|
703
|
+
})
|
|
704
|
+
}
|
|
705
|
+
if (child.pid) {
|
|
706
|
+
try {
|
|
707
|
+
writeFileSync(join(dataDir, SERVE_PID_FILE), `${JSON.stringify({ pid: process.pid, hostPid: child.pid, port })}\n`)
|
|
708
|
+
} catch (e) {
|
|
709
|
+
say(`could not write ${SERVE_PID_FILE}: ${e.message}`)
|
|
710
|
+
}
|
|
711
|
+
}
|
|
502
712
|
createInterface({ input: child.stdout }).on('line', (line) => {
|
|
503
713
|
const p = readyPort(line)
|
|
504
714
|
if (p === null || ready) return
|
|
@@ -509,7 +719,8 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
509
719
|
say(`could not record the port in ${join(dataDir, SERVE_STATE_FILE)}: ${e.message}`)
|
|
510
720
|
}
|
|
511
721
|
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`)
|
|
722
|
+
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`)
|
|
723
|
+
else say(`the host's own log continues in ${join(dataDir, 'host.log')}`)
|
|
513
724
|
})
|
|
514
725
|
|
|
515
726
|
/*
|
|
@@ -532,11 +743,13 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
532
743
|
|
|
533
744
|
return await new Promise((resolve) => {
|
|
534
745
|
child.on('error', (e) => {
|
|
746
|
+
removeServePid(dataDir, process.pid)
|
|
535
747
|
say(`could not start the host (${plan.command}): ${e.message}`)
|
|
536
748
|
resolve(1)
|
|
537
749
|
})
|
|
538
750
|
child.on('exit', (code, signal) => {
|
|
539
751
|
for (const s of signals) process.off(s, forward)
|
|
752
|
+
removeServePid(dataDir, process.pid)
|
|
540
753
|
if (!ready && code !== 0) {
|
|
541
754
|
const pid = lockHolderPid(dataDir)
|
|
542
755
|
if (pid && pid !== child.pid && isAlive(pid)) {
|
|
@@ -546,6 +759,7 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
546
759
|
)
|
|
547
760
|
}
|
|
548
761
|
}
|
|
762
|
+
if (logFd !== null) say(`the host exited (${signal ?? code})`)
|
|
549
763
|
resolve(code ?? (signal ? 1 : 0))
|
|
550
764
|
})
|
|
551
765
|
})
|
|
@@ -559,3 +773,473 @@ export function rotateToken({ env, home }) {
|
|
|
559
773
|
say('A serve that is running keeps the old one until it restarts; clients read the new one with --connection.')
|
|
560
774
|
return 0
|
|
561
775
|
}
|
|
776
|
+
|
|
777
|
+
// ── --detach ────────────────────────────────────────────────────────────────────────────────
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* What a detached launcher needs from the `--detach` that started it. A process created through WMI
|
|
781
|
+
* does not inherit this environment: it gets the account's default one. Everything else it needs
|
|
782
|
+
* (the token, the port) is in the data folder. Never a secret.
|
|
783
|
+
*/
|
|
784
|
+
export const CARRIED_ENV = ['CC_DATA_DIR', 'CENTRALU_HOST_ENTRY', 'CENTRALU_MANAGED']
|
|
785
|
+
|
|
786
|
+
export function encodeChildSpec({ env, log }) {
|
|
787
|
+
const carried = {}
|
|
788
|
+
for (const k of CARRIED_ENV) if (typeof env[k] === 'string' && env[k]) carried[k] = env[k]
|
|
789
|
+
return Buffer.from(JSON.stringify({ log, env: carried }), 'utf8').toString('base64url')
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** `{ log, env }` from `--detached-child=`, only the carried variables; null when it is not one */
|
|
793
|
+
export function decodeChildSpec(raw) {
|
|
794
|
+
try {
|
|
795
|
+
const v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'))
|
|
796
|
+
if (typeof v?.log !== 'string' || !v.log) return null
|
|
797
|
+
const env = {}
|
|
798
|
+
for (const k of CARRIED_ENV) if (typeof v.env?.[k] === 'string') env[k] = v.env[k]
|
|
799
|
+
return { log: v.log, env }
|
|
800
|
+
} catch {
|
|
801
|
+
return null
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* How `--detach` starts the launcher so that it outlives the session that asked (measured,
|
|
807
|
+
* docs/plans/remote-hub.md §10.4):
|
|
808
|
+
*
|
|
809
|
+
* setsid posix: a new session, no terminal, nothing from the ssh session's process group. Once
|
|
810
|
+
* `--detach` returns, the launcher is reparented to init (the second fork of the usual
|
|
811
|
+
* double fork); it opens no terminal, so it can never get a controlling one back
|
|
812
|
+
* wmi Windows: `Win32_Process.Create`. Windows' OpenSSH ends every process of a session with
|
|
813
|
+
* it, `Start-Process` included; a process WMI creates is not part of the session
|
|
814
|
+
* wsl inside WSL: the same WMI call from the Windows side, creating `wsl.exe -d <distro>
|
|
815
|
+
* --exec <launcher>`. A `setsid` launcher inside the distro survives the session but not the
|
|
816
|
+
* distro, which WSL stops about 15 s after its last `wsl.exe` client exits; this
|
|
817
|
+
* `wsl.exe` is a client for as long as the host runs, and ending it ends the host
|
|
818
|
+
*/
|
|
819
|
+
export function detachHow(platform, env, interop = wslInterop) {
|
|
820
|
+
if (platform === 'win32') return 'wmi'
|
|
821
|
+
if (platform === 'linux' && env.WSL_DISTRO_NAME && interop()) return 'wsl'
|
|
822
|
+
return 'setsid'
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function wslInterop() {
|
|
826
|
+
return existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') || existsSync('/proc/sys/fs/binfmt_misc/WSLInterop-late')
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* One argument for a Windows command line, as `CommandLineToArgvW` and the C runtime read it: always
|
|
831
|
+
* quoted, backslashes before the closing quote doubled. A double quote or a line break cannot be a
|
|
832
|
+
* path on Windows and is refused rather than escaped.
|
|
833
|
+
*/
|
|
834
|
+
export function windowsArg(arg) {
|
|
835
|
+
const s = String(arg)
|
|
836
|
+
if (/["\r\n\0]/.test(s)) throw new Error(`cannot pass ${JSON.stringify(s)} on a Windows command line`)
|
|
837
|
+
return `"${s.replace(/(\\+)$/, '$1$1')}"`
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** The PowerShell that asks WMI to create `commandLine`. Values cross as base64, so no quoting can break */
|
|
841
|
+
export function wmiScript({ commandLine, cwd = null }) {
|
|
842
|
+
const text = (s) => `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(s, 'utf8').toString('base64')}'))`
|
|
843
|
+
return [
|
|
844
|
+
"$ErrorActionPreference = 'Stop'",
|
|
845
|
+
'[Console]::OutputEncoding = [Text.Encoding]::UTF8',
|
|
846
|
+
`$a = @{ CommandLine = ${text(commandLine)} }`,
|
|
847
|
+
...(cwd ? [`$a.CurrentDirectory = ${text(cwd)}`] : []),
|
|
848
|
+
// No console window when this runs from a desktop session; left out where it cannot be built
|
|
849
|
+
'try { $a.ProcessStartupInformation = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 } } catch { }',
|
|
850
|
+
'try {',
|
|
851
|
+
' $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $a',
|
|
852
|
+
" if ($r.ReturnValue -eq 21 -and $a.ContainsKey('ProcessStartupInformation')) { $a.Remove('ProcessStartupInformation'); $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $a }",
|
|
853
|
+
' "CENTRALU-WMI $($r.ReturnValue) $($r.ProcessId)"',
|
|
854
|
+
'} catch { "CENTRALU-WMI-ERROR $($_.Exception.Message)" }',
|
|
855
|
+
].join('\n')
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** `Win32_Process.Create`'s return values (Microsoft's documentation of the method) */
|
|
859
|
+
const WMI_CODES = { 2: 'access denied', 3: 'insufficient privilege', 8: 'unknown failure', 9: 'path not found', 21: 'invalid parameter' }
|
|
860
|
+
|
|
861
|
+
/** What the WMI script said: `{ ok: true, pid }` or `{ ok: false, message }` */
|
|
862
|
+
export function parseWmiAnswer(stdout) {
|
|
863
|
+
const text = String(stdout ?? '')
|
|
864
|
+
const m = /CENTRALU-WMI (\d+) (\d*)/.exec(text)
|
|
865
|
+
if (m) {
|
|
866
|
+
const rv = Number(m[1])
|
|
867
|
+
if (rv === 0 && Number(m[2]) > 0) return { ok: true, pid: Number(m[2]) }
|
|
868
|
+
return { ok: false, message: `WMI's Win32_Process.Create returned ${rv}${WMI_CODES[rv] ? ` (${WMI_CODES[rv]})` : ''}` }
|
|
869
|
+
}
|
|
870
|
+
const e = /CENTRALU-WMI-ERROR (.*)/.exec(text)
|
|
871
|
+
if (e) return { ok: false, message: `WMI refused to create the process: ${e[1].trim()}` }
|
|
872
|
+
return { ok: false, message: 'PowerShell did not answer the WMI request' }
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* The sentence for a start WMI did not make. Some managed machines block process creation through
|
|
877
|
+
* WMI (Microsoft Defender's attack surface reduction rule "Block process creations originating from
|
|
878
|
+
* PSExec and WMI commands", off by default); the hub then runs the host bound to its link
|
|
879
|
+
* (docs/plans/remote-hub.md §10.9, decision 3).
|
|
880
|
+
*/
|
|
881
|
+
export function wmiBlockedMessage(detail) {
|
|
882
|
+
return (
|
|
883
|
+
`Windows did not start Centralu through WMI (${detail}). Some managed Windows machines block it ` +
|
|
884
|
+
'(Microsoft Defender\'s attack surface reduction rule "Block process creations originating from PSExec and WMI commands"). ' +
|
|
885
|
+
'Centralu can run there while this computer is linked instead, and stops when the link does.'
|
|
886
|
+
)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** Windows PowerShell 5.1, by full path: under %SystemRoot% on Windows, through the drive WSL mounts inside a distro */
|
|
890
|
+
export function windowsPowershell(how, env, mounts) {
|
|
891
|
+
return windowsTool(how, env, ['System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'], mounts)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** A program under the Windows folder, by full path, from Windows or from inside a WSL distro; null when unreachable */
|
|
895
|
+
function windowsTool(how, env, rel, mounts = () => readFileSync('/proc/mounts', 'utf8')) {
|
|
896
|
+
if (how === 'wmi') return [env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', ...rel].join('\\')
|
|
897
|
+
let text = ''
|
|
898
|
+
try {
|
|
899
|
+
text = mounts()
|
|
900
|
+
} catch {
|
|
901
|
+
// no /proc: not WSL after all
|
|
902
|
+
}
|
|
903
|
+
for (const line of text.split('\n')) {
|
|
904
|
+
const [, at, type] = line.split(' ')
|
|
905
|
+
if (!at || !(type === '9p' || type === 'drvfs')) continue
|
|
906
|
+
const candidate = [at, 'Windows', ...rel].join('/')
|
|
907
|
+
if (existsSync(candidate)) return candidate
|
|
908
|
+
}
|
|
909
|
+
return null
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function runCaptured(command, args, env) {
|
|
913
|
+
return new Promise((resolve) => {
|
|
914
|
+
let stdout = ''
|
|
915
|
+
let stderr = ''
|
|
916
|
+
let child
|
|
917
|
+
try {
|
|
918
|
+
child = spawn(command, args, { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
919
|
+
} catch (e) {
|
|
920
|
+
return resolve({ code: null, stdout, stderr: e.message })
|
|
921
|
+
}
|
|
922
|
+
child.stdout.on('data', (b) => (stdout += String(b)))
|
|
923
|
+
child.stderr.on('data', (b) => (stderr += String(b)))
|
|
924
|
+
child.once('error', (e) => resolve({ code: null, stdout, stderr: e.message }))
|
|
925
|
+
child.once('close', (code) => resolve({ code, stdout, stderr }))
|
|
926
|
+
})
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Starts the launcher outside this session (`detachHow`). `{ ok: true, pid, exited?, alive? }` once
|
|
931
|
+
* something was created, `{ ok: false, blocked, message }` when nothing was: `blocked` is the WMI
|
|
932
|
+
* refusal the hub falls back from.
|
|
933
|
+
*/
|
|
934
|
+
export async function startDetached({ how, execPath, args, env, cwd }) {
|
|
935
|
+
if (how === 'setsid') {
|
|
936
|
+
return await new Promise((resolve) => {
|
|
937
|
+
let child
|
|
938
|
+
try {
|
|
939
|
+
child = spawn(execPath, args, { detached: true, stdio: 'ignore', cwd, env, windowsHide: true })
|
|
940
|
+
} catch (e) {
|
|
941
|
+
return resolve({ ok: false, blocked: false, message: `could not start ${execPath}: ${e.message}` })
|
|
942
|
+
}
|
|
943
|
+
child.once('error', (e) => resolve({ ok: false, blocked: false, message: `could not start ${execPath}: ${e.message}` }))
|
|
944
|
+
child.once('spawn', () => {
|
|
945
|
+
const exited = new Promise((r) => child.once('exit', (code, signal) => r(signal ?? code)))
|
|
946
|
+
child.unref()
|
|
947
|
+
resolve({ ok: true, pid: child.pid, exited })
|
|
948
|
+
})
|
|
949
|
+
})
|
|
950
|
+
}
|
|
951
|
+
const ps = windowsPowershell(how, env)
|
|
952
|
+
if (!ps) return { ok: false, blocked: true, message: wmiBlockedMessage('Windows PowerShell is not reachable from this WSL distro') }
|
|
953
|
+
let commandLine
|
|
954
|
+
try {
|
|
955
|
+
if (how === 'wmi') commandLine = [execPath, ...args].map(windowsArg).join(' ')
|
|
956
|
+
else {
|
|
957
|
+
const distro = env.WSL_DISTRO_NAME ?? ''
|
|
958
|
+
const user = userInfo().username
|
|
959
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(distro)) throw new Error(`not a WSL distro name: ${distro}`)
|
|
960
|
+
if (!/^[A-Za-z_][A-Za-z0-9_.-]{0,31}$/.test(user)) throw new Error(`cannot name the user ${user} to wsl.exe`)
|
|
961
|
+
commandLine = ['wsl.exe', '-d', distro, '-u', user, '--cd', cwd, '--exec', execPath, ...args].map(windowsArg).join(' ')
|
|
962
|
+
}
|
|
963
|
+
} catch (e) {
|
|
964
|
+
return { ok: false, blocked: false, message: e.message }
|
|
965
|
+
}
|
|
966
|
+
const script = wmiScript({ commandLine, cwd: how === 'wmi' ? cwd : null })
|
|
967
|
+
const r = await runCaptured(ps, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], env)
|
|
968
|
+
const answer = parseWmiAnswer(r.stdout)
|
|
969
|
+
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)) }
|
|
970
|
+
// A Windows pid can be watched from Windows; from inside WSL it names nothing here
|
|
971
|
+
/*
|
|
972
|
+
* Measured on a Windows 11 laptop (2026-10-08): a `wsl.exe` that WMI creates can hang without
|
|
973
|
+
* running anything in the distro (it starts a second wsl.exe and waits), while `wsl.exe --list`
|
|
974
|
+
* from the same context answers. `--detach` reports that as a start that never ran, and ends what
|
|
975
|
+
* it created rather than leave it waiting: this pid and its children, nothing else.
|
|
976
|
+
*/
|
|
977
|
+
const taskkill = windowsTool(how, env, ['System32', 'taskkill.exe'])
|
|
978
|
+
const stop = taskkill ? () => runCaptured(taskkill, ['/PID', String(answer.pid), '/T', '/F'], env) : null
|
|
979
|
+
return { ok: true, pid: answer.pid, alive: how === 'wmi' ? () => isAlive(answer.pid) : null, stop }
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** The last lines of a file, for a reason; empty when it cannot be read */
|
|
983
|
+
function tail(file, n = 15) {
|
|
984
|
+
try {
|
|
985
|
+
return readFileSync(file, 'utf8').trimEnd().split(/\r?\n/).slice(-n).join('\n')
|
|
986
|
+
} catch {
|
|
987
|
+
return ''
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* `centralu serve --detach` (docs/plans/remote-hub.md §10.4, S9): starts `serve` outside this
|
|
995
|
+
* session (`detachHow`) and returns once its host answers a hello, so the caller, typically the
|
|
996
|
+
* hub over ssh, can connect at once.
|
|
997
|
+
*
|
|
998
|
+
* stdout carries one JSON line, read by the hub (`links/tunnel.ts`): the connection line with
|
|
999
|
+
* `hostRunning: true` and `detach: { ok: true, how, already }`, or `{ v: 1, detach: { ok: false,
|
|
1000
|
+
* how, reason, message } }`. `reason` is `wmi_blocked` when WMI created nothing or created something
|
|
1001
|
+
* that never ran, which the hub answers with a link-bound start; `exited` and `timeout` quote the
|
|
1002
|
+
* end of serve.log. Exit codes do not survive Windows' ssh (agent-host.md §4.8), so the line is
|
|
1003
|
+
* what counts; the exit code is 0 or 1 for a person at a prompt.
|
|
1004
|
+
*
|
|
1005
|
+
* `start` and the two waits are parameters so tests can stand in for WMI.
|
|
1006
|
+
*/
|
|
1007
|
+
export async function runDetach({
|
|
1008
|
+
env,
|
|
1009
|
+
home,
|
|
1010
|
+
entry,
|
|
1011
|
+
version,
|
|
1012
|
+
cliPath,
|
|
1013
|
+
port = null,
|
|
1014
|
+
platform = process.platform,
|
|
1015
|
+
execPath = process.execPath,
|
|
1016
|
+
cwd = process.cwd(),
|
|
1017
|
+
start = startDetached,
|
|
1018
|
+
how = detachHow(platform, env),
|
|
1019
|
+
timeoutMs = 30_000,
|
|
1020
|
+
noStartMs = 10_000,
|
|
1021
|
+
write = (line) => process.stdout.write(line),
|
|
1022
|
+
}) {
|
|
1023
|
+
const dataDir = serveDataDir(env, home)
|
|
1024
|
+
// Created here, so the token exists before the launcher reads it and `answered` reads it again
|
|
1025
|
+
ensureServeState(dataDir, platform)
|
|
1026
|
+
keepLauncher(dataDir, cliPath, env)
|
|
1027
|
+
const fail = (reason, message) => {
|
|
1028
|
+
say(message)
|
|
1029
|
+
write(`${JSON.stringify({ v: 1, detach: { ok: false, how, reason, message } })}\n`)
|
|
1030
|
+
return 1
|
|
1031
|
+
}
|
|
1032
|
+
const answered = async (already) => {
|
|
1033
|
+
const d = await describe({ env, dataDir, state: ensureServeState(dataDir, platform), entry, version })
|
|
1034
|
+
if (!d?.hostRunning) return false
|
|
1035
|
+
write(`${connectionLine({ ...d, detach: { ok: true, how, already } })}\n`)
|
|
1036
|
+
return true
|
|
1037
|
+
}
|
|
1038
|
+
if (await answered(true)) return 0
|
|
1039
|
+
|
|
1040
|
+
const log = join(dataDir, SERVE_LOG_FILE)
|
|
1041
|
+
// One start per log: the last one's is kept beside it, and its absence is how a start that never ran shows
|
|
1042
|
+
try {
|
|
1043
|
+
renameSync(log, `${log}.1`)
|
|
1044
|
+
} catch {
|
|
1045
|
+
// there was none
|
|
1046
|
+
}
|
|
1047
|
+
const args = [cliPath, 'serve', ...(port !== null ? ['--port', String(port)] : []), `${CHILD_FLAG}${encodeChildSpec({ env, log })}`]
|
|
1048
|
+
let started
|
|
1049
|
+
try {
|
|
1050
|
+
started = await start({ how, execPath, args, env, cwd })
|
|
1051
|
+
} catch (e) {
|
|
1052
|
+
return fail('failed', `could not start centralu serve in the background: ${e.message}`)
|
|
1053
|
+
}
|
|
1054
|
+
if (!started.ok) return fail(started.blocked ? 'wmi_blocked' : 'failed', started.message)
|
|
1055
|
+
say(`started centralu serve in the background (${how}, pid ${started.pid ?? 'unknown'}); waiting for its host`)
|
|
1056
|
+
|
|
1057
|
+
let exited
|
|
1058
|
+
started.exited?.then((c) => (exited = c ?? 'unknown'))
|
|
1059
|
+
const t0 = Date.now()
|
|
1060
|
+
while (Date.now() - t0 < timeoutMs) {
|
|
1061
|
+
if (await answered(false)) return 0
|
|
1062
|
+
const why = tail(log)
|
|
1063
|
+
if (exited !== undefined) return fail('exited', `centralu serve exited (${exited}) before its host answered${why ? `:\n${why}` : ''}`)
|
|
1064
|
+
const gone = started.alive?.() === false
|
|
1065
|
+
if (how !== 'setsid' && !existsSync(log) && (gone || Date.now() - t0 > noStartMs)) {
|
|
1066
|
+
// WMI said yes, and nothing ran: the process was stopped before Node started, or hangs (`startDetached`)
|
|
1067
|
+
if (!gone) await started.stop?.()
|
|
1068
|
+
return fail('wmi_blocked', wmiBlockedMessage(gone ? `process ${started.pid} ended before it started` : `nothing started within ${Math.round(noStartMs / 1000)} s`))
|
|
1069
|
+
}
|
|
1070
|
+
if (gone) return fail('exited', `centralu serve exited before its host answered${why ? `:\n${why}` : ''}`)
|
|
1071
|
+
await sleep(250)
|
|
1072
|
+
}
|
|
1073
|
+
const why = tail(log)
|
|
1074
|
+
return fail('timeout', `the host did not answer within ${Math.round(timeoutMs / 1000)} s${why ? `:\n${why}` : ''}`)
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* The launcher `--detach` started: `runServe` writing to serve.log. The carried variables are
|
|
1079
|
+
* already in `env` (centralu.mjs merges them before it looks for the host).
|
|
1080
|
+
*/
|
|
1081
|
+
export async function runDetachedChild({ spec, ...opts }) {
|
|
1082
|
+
let fd = null
|
|
1083
|
+
try {
|
|
1084
|
+
fd = openSync(spec.log, 'a')
|
|
1085
|
+
sink = (text) => {
|
|
1086
|
+
try {
|
|
1087
|
+
writeSync(fd, text)
|
|
1088
|
+
} catch {
|
|
1089
|
+
// nowhere left to say it
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
} catch {
|
|
1093
|
+
// no log: say() keeps stderr, which nobody reads; --detach then reports the start as never run
|
|
1094
|
+
}
|
|
1095
|
+
say(`started in the background (pid ${process.pid}, Node ${process.version})`)
|
|
1096
|
+
try {
|
|
1097
|
+
return await runServe({ ...opts, logFd: fd })
|
|
1098
|
+
} catch (e) {
|
|
1099
|
+
say(e?.message ?? String(e))
|
|
1100
|
+
return 1
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// ── --stop ──────────────────────────────────────────────────────────────────────────────────
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Asks a running host to stop (`host.stop`, an additive RPC): a hello with the token, then the call.
|
|
1108
|
+
* `{ ok: true }` once the host took it, `{ ok: false, message, protocolVersion? }` otherwise; the
|
|
1109
|
+
* last names the host's protocol when the hello was refused for it, so the caller can ask again.
|
|
1110
|
+
*/
|
|
1111
|
+
export function askHostToStop({ port, token, protocolVersion, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
|
|
1112
|
+
if (typeof WebSocketImpl !== 'function') return Promise.resolve({ ok: false, message: `Node ${process.version} has no WebSocket to ask with` })
|
|
1113
|
+
return new Promise((resolve) => {
|
|
1114
|
+
let ws = null
|
|
1115
|
+
let settled = false
|
|
1116
|
+
const done = (result) => {
|
|
1117
|
+
if (settled) return
|
|
1118
|
+
settled = true
|
|
1119
|
+
clearTimeout(timer)
|
|
1120
|
+
try {
|
|
1121
|
+
ws?.close()
|
|
1122
|
+
} catch {
|
|
1123
|
+
// already closed
|
|
1124
|
+
}
|
|
1125
|
+
resolve(result)
|
|
1126
|
+
}
|
|
1127
|
+
const timer = setTimeout(() => done({ ok: false, message: 'no answer in time' }), timeoutMs)
|
|
1128
|
+
try {
|
|
1129
|
+
ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
|
|
1130
|
+
} catch (e) {
|
|
1131
|
+
done({ ok: false, message: e.message })
|
|
1132
|
+
return
|
|
1133
|
+
}
|
|
1134
|
+
ws.onopen = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
|
|
1135
|
+
ws.onmessage = (ev) => {
|
|
1136
|
+
let m
|
|
1137
|
+
try {
|
|
1138
|
+
m = JSON.parse(String(ev.data))
|
|
1139
|
+
} catch {
|
|
1140
|
+
return
|
|
1141
|
+
}
|
|
1142
|
+
if (m?.kind === 'hello_ok') ws.send(JSON.stringify({ kind: 'rpc', id: 'stop', method: 'host.stop', params: {} }))
|
|
1143
|
+
else if (m?.kind === 'res' && m.id === 'stop') done(m.ok ? { ok: true } : { ok: false, message: m.error?.message ?? 'refused' })
|
|
1144
|
+
else if (m?.kind === 'res' && m.ok === false) {
|
|
1145
|
+
const pv = m.error?.code === 'version_mismatch' ? (m.error?.data?.protocolVersion ?? mismatchServerVersion(m.error.message)) : null
|
|
1146
|
+
done({ ok: false, message: m.error?.message ?? 'refused', ...(Number.isInteger(pv) ? { protocolVersion: pv } : {}) })
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
ws.onerror = () => done({ ok: false, message: 'could not connect' })
|
|
1150
|
+
ws.onclose = () => done({ ok: false, message: 'the connection closed' })
|
|
1151
|
+
})
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
async function waitUntil(pred, ms) {
|
|
1155
|
+
const t0 = Date.now()
|
|
1156
|
+
while (Date.now() - t0 < ms) {
|
|
1157
|
+
if (await pred()) return true
|
|
1158
|
+
await sleep(200)
|
|
1159
|
+
}
|
|
1160
|
+
return pred()
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* `centralu serve --stop` (docs/plans/remote-hub.md §10.4, S10): asks the running host to stop over
|
|
1165
|
+
* its own socket and waits until it has, so its shutdown runs (its agents, terminals and app
|
|
1166
|
+
* processes stopped in order, the store closed). Measured on Windows: killing the WMI-started
|
|
1167
|
+
* launcher ended the host 143 ms later with no "shutting down" line, because the host sits in
|
|
1168
|
+
* libuv's kill-on-close job object; so on Windows killing is the last resort, not the way.
|
|
1169
|
+
*
|
|
1170
|
+
* Then, only for a host that did not go: posix SIGTERM to the launcher (which passes it to the host,
|
|
1171
|
+
* the same clean stop), and on Windows ending the host. A host from before `host.stop` gets that
|
|
1172
|
+
* too. The launcher ends with its host; one still there afterwards is ended.
|
|
1173
|
+
*
|
|
1174
|
+
* Only the processes serve.pid and host.lock name, and only while they agree with each other and
|
|
1175
|
+
* the host answered with this folder's token: a stale pid is never signalled.
|
|
1176
|
+
*
|
|
1177
|
+
* stdout: one line, `{ v: 1, stop: { ok, wasRunning, how?, message? } }`.
|
|
1178
|
+
*/
|
|
1179
|
+
export async function runStop({
|
|
1180
|
+
env,
|
|
1181
|
+
home,
|
|
1182
|
+
entry,
|
|
1183
|
+
platform = process.platform,
|
|
1184
|
+
timeoutMs = 30_000,
|
|
1185
|
+
graceMs = 15_000,
|
|
1186
|
+
kill = (pid, sig) => process.kill(pid, sig),
|
|
1187
|
+
write = (line) => process.stdout.write(line),
|
|
1188
|
+
}) {
|
|
1189
|
+
const dataDir = serveDataDir(env, home)
|
|
1190
|
+
const report = (stop) => {
|
|
1191
|
+
if (stop.message) say(stop.message)
|
|
1192
|
+
write(`${JSON.stringify({ v: 1, stop })}\n`)
|
|
1193
|
+
return stop.ok ? 0 : 1
|
|
1194
|
+
}
|
|
1195
|
+
const state = readServeState(dataDir)
|
|
1196
|
+
if (!state) return report({ ok: true, wasRunning: false, message: `no centralu serve has run for ${dataDir}` })
|
|
1197
|
+
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
1198
|
+
const installed = protocolVersionOf(entry) ?? 0
|
|
1199
|
+
const probe = () => probeHost({ port, token: state.token, protocolVersion: installed })
|
|
1200
|
+
const first = await probe()
|
|
1201
|
+
if (!first.running) return report({ ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}` })
|
|
1202
|
+
|
|
1203
|
+
const hostPid = lockHolderPid(dataDir)
|
|
1204
|
+
const launcher = readServePid(dataDir)
|
|
1205
|
+
// serve.pid is only believed when it names the host that holds the folder now
|
|
1206
|
+
const launcherPid = launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
|
|
1207
|
+
const down = async () => !(await probe()).running && !(hostPid && isAlive(hostPid))
|
|
1208
|
+
|
|
1209
|
+
let asked = await askHostToStop({ port, token: state.token, protocolVersion: first.protocolVersion ?? installed })
|
|
1210
|
+
if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, token: state.token, protocolVersion: asked.protocolVersion })
|
|
1211
|
+
let how = 'asked'
|
|
1212
|
+
let stopped = asked.ok && (await waitUntil(down, timeoutMs))
|
|
1213
|
+
let note = asked.ok ? null : `the host did not take the request to stop (${asked.message})`
|
|
1214
|
+
if (!stopped) {
|
|
1215
|
+
const target = platform !== 'win32' && launcherPid ? launcherPid : hostPid
|
|
1216
|
+
if (target) {
|
|
1217
|
+
how = platform === 'win32' ? 'ended' : 'signal'
|
|
1218
|
+
try {
|
|
1219
|
+
kill(target, 'SIGTERM')
|
|
1220
|
+
} catch {
|
|
1221
|
+
// gone in the meantime
|
|
1222
|
+
}
|
|
1223
|
+
stopped = await waitUntil(down, platform === 'win32' ? 5_000 : graceMs)
|
|
1224
|
+
if (!stopped && hostPid && isAlive(hostPid)) {
|
|
1225
|
+
how = 'ended'
|
|
1226
|
+
try {
|
|
1227
|
+
kill(hostPid, 'SIGKILL')
|
|
1228
|
+
} catch {
|
|
1229
|
+
// gone in the meantime
|
|
1230
|
+
}
|
|
1231
|
+
stopped = await waitUntil(down, 5_000)
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
if (how === 'ended') note = `${note ? `${note}; ` : ''}ended the host without its shutdown`
|
|
1235
|
+
}
|
|
1236
|
+
if (launcherPid && launcherPid !== process.pid && !(await waitUntil(() => !isAlive(launcherPid), 5_000))) {
|
|
1237
|
+
try {
|
|
1238
|
+
kill(launcherPid, 'SIGKILL')
|
|
1239
|
+
} catch {
|
|
1240
|
+
// gone in the meantime
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
if (!stopped) return report({ ok: false, wasRunning: true, how, message: `the host on 127.0.0.1:${port} is still running${note ? ` (${note})` : ''}` })
|
|
1244
|
+
return report({ ok: true, wasRunning: true, how, ...(note ? { message: note } : {}) })
|
|
1245
|
+
}
|
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.13",
|
|
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.13",
|
|
41
|
+
"@centralu/linux-arm64": "0.1.0-beta.13",
|
|
42
|
+
"@centralu/linux-x64": "0.1.0-beta.13",
|
|
43
|
+
"@centralu/win32-x64": "0.1.0-beta.13"
|
|
44
44
|
}
|
|
45
45
|
}
|