centralu 0.1.0-beta.10 → 0.1.0-beta.12
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 +63 -1
- package/bin/platform.mjs +17 -0
- package/bin/serve.mjs +561 -0
- package/package.json +5 -5
package/bin/centralu.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
busyMessage,
|
|
29
29
|
earlyExitMessage,
|
|
30
30
|
executableIn,
|
|
31
|
+
hostDirIn,
|
|
31
32
|
installedPaths,
|
|
32
33
|
isBusyError,
|
|
33
34
|
launchPlan,
|
|
@@ -40,7 +41,8 @@ import {
|
|
|
40
41
|
webview2Status,
|
|
41
42
|
windowsInstall,
|
|
42
43
|
} from './platform.mjs'
|
|
43
|
-
import {
|
|
44
|
+
import { parseServeArgs, printConnection, rotateToken, runServe, SERVE_HELP } from './serve.mjs'
|
|
45
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
44
46
|
import { createRequire } from 'node:module'
|
|
45
47
|
import { homedir } from 'node:os'
|
|
46
48
|
import { dirname, join } from 'node:path'
|
|
@@ -104,6 +106,37 @@ function explainMissing() {
|
|
|
104
106
|
)
|
|
105
107
|
}
|
|
106
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The bundled host's entry (`resources/host/main.mjs`) for `centralu serve`, or exit with why not.
|
|
111
|
+
*
|
|
112
|
+
* `CENTRALU_HOST_ENTRY` points it at another host instead: a source checkout's
|
|
113
|
+
* `packages/agent-host/src/main.ts` while developing, or a freshly bundled `resources/host/main.mjs`.
|
|
114
|
+
* The tests start `serve` that way (tooling/launcher-serve.test.ts).
|
|
115
|
+
*/
|
|
116
|
+
function requireHostEntry() {
|
|
117
|
+
if (process.env.CENTRALU_HOST_ENTRY) return process.env.CENTRALU_HOST_ENTRY
|
|
118
|
+
if (!TARGET) {
|
|
119
|
+
console.error(explainMissing())
|
|
120
|
+
process.exit(1)
|
|
121
|
+
}
|
|
122
|
+
let root
|
|
123
|
+
try {
|
|
124
|
+
root = dirname(require.resolve(`${TARGET.pkg}/package.json`))
|
|
125
|
+
} catch {
|
|
126
|
+
console.error(explainMissing())
|
|
127
|
+
process.exit(1)
|
|
128
|
+
}
|
|
129
|
+
const entry = join(hostDirIn(PLATFORM, join(root, TARGET.artifact)), 'main.mjs')
|
|
130
|
+
if (!existsSync(entry)) {
|
|
131
|
+
console.error(
|
|
132
|
+
`The host is missing from ${TARGET.pkg} (looked for ${entry}).\n` +
|
|
133
|
+
'Linux packages published before `centralu serve` existed do not carry it. Update with `npm i -g centralu`.',
|
|
134
|
+
)
|
|
135
|
+
process.exit(1)
|
|
136
|
+
}
|
|
137
|
+
return entry
|
|
138
|
+
}
|
|
139
|
+
|
|
107
140
|
function requireApp() {
|
|
108
141
|
const app = bundledApp()
|
|
109
142
|
if (!app) {
|
|
@@ -484,6 +517,8 @@ const HELP = `${APP_NAME} ${pkg.version}
|
|
|
484
517
|
Windows: %LOCALAPPDATA%\\Programs\\Centralu and a Start menu shortcut)
|
|
485
518
|
centralu uninstall Removes that registration (conversation history is kept)
|
|
486
519
|
centralu update Upgrades if a new version is available
|
|
520
|
+
centralu serve Runs the host alone on this machine, for the app on another computer
|
|
521
|
+
to reach over SSH (\`centralu serve --help\`)
|
|
487
522
|
centralu --version Prints the version
|
|
488
523
|
|
|
489
524
|
Requires: Node 22+, and the claude or codex CLI (the app's first screen reports the status)`
|
|
@@ -499,6 +534,33 @@ switch (cmd) {
|
|
|
499
534
|
case 'update':
|
|
500
535
|
await update()
|
|
501
536
|
break
|
|
537
|
+
case 'serve': {
|
|
538
|
+
// No update notice here: `--connection`'s stdout is read by a program, and a foreground
|
|
539
|
+
// server's terminal is a log.
|
|
540
|
+
const args = parseServeArgs(rest)
|
|
541
|
+
if (args.error) {
|
|
542
|
+
console.error(args.error)
|
|
543
|
+
process.exit(2)
|
|
544
|
+
}
|
|
545
|
+
if (args.mode === 'help') {
|
|
546
|
+
console.log(SERVE_HELP)
|
|
547
|
+
break
|
|
548
|
+
}
|
|
549
|
+
try {
|
|
550
|
+
if (args.mode === 'rotate') {
|
|
551
|
+
process.exitCode = rotateToken({ env: process.env, home: HOME })
|
|
552
|
+
break
|
|
553
|
+
}
|
|
554
|
+
// realpath: npm's bin is a symlink to this file, and the launcher must not depend on the link
|
|
555
|
+
const opts = { env: process.env, home: HOME, entry: requireHostEntry(), version: pkg.version, cliPath: realpathSync(process.argv[1]) }
|
|
556
|
+
process.exitCode = args.mode === 'connection' ? await printConnection(opts) : await runServe({ ...opts, port: args.port })
|
|
557
|
+
} catch (e) {
|
|
558
|
+
// A state file that cannot be read or written: one sentence, not a stack, and nothing on stdout
|
|
559
|
+
console.error(`centralu serve: ${e?.message ?? e}`)
|
|
560
|
+
process.exitCode = 1
|
|
561
|
+
}
|
|
562
|
+
break
|
|
563
|
+
}
|
|
502
564
|
case '--version':
|
|
503
565
|
case '-v':
|
|
504
566
|
console.log(pkg.version)
|
package/bin/platform.mjs
CHANGED
|
@@ -53,6 +53,23 @@ export function executableIn(platform, app) {
|
|
|
53
53
|
return platform === 'win32' ? win32.join(app, 'centralu.exe') : app
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The bundled host (`resources/host`, the folder whose `main.mjs` the app runs) inside a
|
|
58
|
+
* platform package, for `centralu serve` (#82).
|
|
59
|
+
*
|
|
60
|
+
* - macOS: inside the bundle, where Tauri puts `bundle.resources`.
|
|
61
|
+
* - Windows: beside the exe, which is already how the folder ships (W1 in #307).
|
|
62
|
+
* - Linux: a `host/` folder next to the AppImage. The host inside the AppImage sits in a
|
|
63
|
+
* squashfs that is only reachable by mounting it (FUSE, which headless servers often lack)
|
|
64
|
+
* or by extracting it on every start, so `scripts/release-npm.mts` ships the same folder a
|
|
65
|
+
* second time, unpacked. It is 11 MB; the AppImage is several times that.
|
|
66
|
+
*/
|
|
67
|
+
export function hostDirIn(platform, app) {
|
|
68
|
+
if (platform === 'darwin') return posix.join(app, 'Contents', 'Resources', 'resources', 'host')
|
|
69
|
+
if (platform === 'win32') return win32.join(app, 'resources', 'host')
|
|
70
|
+
return posix.join(posix.dirname(app), 'host')
|
|
71
|
+
}
|
|
72
|
+
|
|
56
73
|
/**
|
|
57
74
|
* How to start the app: the command, its arguments, the spawn options, and whether the
|
|
58
75
|
* launcher stays attached until it exits.
|
package/bin/serve.mjs
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `centralu serve`: the host without the app, for the remote mode's first phase (#82).
|
|
3
|
+
*
|
|
4
|
+
* The person starts this by hand on another machine (an SSH server, later a laptop). It runs the
|
|
5
|
+
* same bundled host the app runs (`resources/host/main.mjs`, on the system Node), with no window
|
|
6
|
+
* and no keeper, bound to 127.0.0.1 only. The app on the person's own computer reaches it through
|
|
7
|
+
* an SSH local forward, so nothing ever listens on a public interface. The design and its limits
|
|
8
|
+
* are in docs/agent-host.md §4.7; the trust argument in docs/security-boundaries.md.
|
|
9
|
+
*
|
|
10
|
+
* `centralu serve --connection` is the one question the client asks over `ssh <target>`: where to
|
|
11
|
+
* forward and how to authenticate. It answers with one JSON line on stdout and nothing else.
|
|
12
|
+
*
|
|
13
|
+
* Everything here takes its inputs as arguments (environment, home, platform) instead of reading
|
|
14
|
+
* `process`, so `tooling/launcher-serve.test.ts` can import it: `centralu.mjs` runs a command at
|
|
15
|
+
* import time and cannot be imported by a test. package.json "files" lists the whole bin/
|
|
16
|
+
* directory, so this ships automatically.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from 'node:child_process'
|
|
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'
|
|
22
|
+
import { dirname, join } from 'node:path'
|
|
23
|
+
import { createInterface } from 'node:readline'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The port `serve` listens on unless told otherwise, and keeps across restarts.
|
|
27
|
+
*
|
|
28
|
+
* Stable, because the client is told it once per connection and the person may write it into an
|
|
29
|
+
* SSH config or a systemd unit. Below 20000 on purpose: every OS lends ports from its ephemeral
|
|
30
|
+
* range to outgoing connections (Linux 32768–60999, macOS and Windows 49152–65535), and the host
|
|
31
|
+
* itself hands out per-app view origins from 20000–32767 (views/origin-ports.ts). A default inside
|
|
32
|
+
* either range could be taken by something transient the day the host restarts.
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_SERVE_PORT = 17175
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The file in the data folder that holds the token and the port, mode 0600.
|
|
38
|
+
*
|
|
39
|
+
* The token is generated once, the first time either `serve` or `serve --connection` runs, and kept:
|
|
40
|
+
* the client stores it (in the OS secret store) and has to find the same one after the host
|
|
41
|
+
* restarts. The port is the one the last `serve` actually listened on, which is how
|
|
42
|
+
* `--connection`, a separate process, learns a `--port` given to `serve` in a systemd unit.
|
|
43
|
+
*/
|
|
44
|
+
export const SERVE_STATE_FILE = 'serve.json'
|
|
45
|
+
|
|
46
|
+
export const SERVE_HELP = `centralu serve: run the Centralu host on this machine, without a window
|
|
47
|
+
|
|
48
|
+
centralu serve Start the host in the foreground, on 127.0.0.1 only
|
|
49
|
+
centralu serve --port <n> ... on that port (kept for the next start; default ${DEFAULT_SERVE_PORT})
|
|
50
|
+
centralu serve --connection Print how to reach it as one JSON line, then exit
|
|
51
|
+
centralu serve --rotate-token Replace the token (a running serve keeps the old one until restarted)
|
|
52
|
+
|
|
53
|
+
The data folder is ~/.centralu (CC_DATA_DIR overrides it). The app on another computer reaches
|
|
54
|
+
this host through an SSH local forward. Stop it with Ctrl+C or SIGTERM.
|
|
55
|
+
|
|
56
|
+
Both serve and --connection keep ~/.centralu/bin/centralu up to date: a launcher with absolute
|
|
57
|
+
paths, for SSH sessions whose PATH has neither npm's global folder nor node.`
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What the arguments after `serve` ask for: `{ mode, port }` or `{ error }`.
|
|
61
|
+
*
|
|
62
|
+
* `mode` is `serve`, `connection`, `rotate` or `help`; `port` is null when not given. `--port` does not go
|
|
63
|
+
* with `--connection`: the answer has to be the port `serve` uses, not one the caller names.
|
|
64
|
+
*/
|
|
65
|
+
export function parseServeArgs(argv) {
|
|
66
|
+
let mode = 'serve'
|
|
67
|
+
let port = null
|
|
68
|
+
for (let i = 0; i < argv.length; i++) {
|
|
69
|
+
const a = argv[i]
|
|
70
|
+
if (a === '--connection' || a === '--rotate-token') {
|
|
71
|
+
const m = a === '--connection' ? 'connection' : 'rotate'
|
|
72
|
+
if (mode !== 'serve' && mode !== 'help' && mode !== m) return { error: '--connection and --rotate-token are separate commands; pass one' }
|
|
73
|
+
if (mode !== 'help') mode = m
|
|
74
|
+
}
|
|
75
|
+
else if (a === '--help' || a === '-h') mode = 'help'
|
|
76
|
+
else if (a === '--port' || a.startsWith('--port=')) {
|
|
77
|
+
const raw = a === '--port' ? argv[++i] : a.slice('--port='.length)
|
|
78
|
+
const n = parsePort(raw)
|
|
79
|
+
if (n === null) return { error: `--port needs a port number between 1 and 65535 (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
|
|
80
|
+
port = n
|
|
81
|
+
} else return { error: `unknown option for serve: ${a}\n\n${SERVE_HELP}` }
|
|
82
|
+
}
|
|
83
|
+
if ((mode === 'connection' || mode === 'rotate') && port !== null) {
|
|
84
|
+
return { error: '--port goes with `centralu serve`; `--connection` reports the port serve listens on' }
|
|
85
|
+
}
|
|
86
|
+
return { mode, port }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parsePort(raw) {
|
|
90
|
+
if (typeof raw !== 'string' || !/^\d{1,5}$/.test(raw)) return null
|
|
91
|
+
const n = Number(raw)
|
|
92
|
+
return n >= 1 && n <= 65535 ? n : null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The data folder: the host's own rule (`dataRoot()` in agent-host), CC_DATA_DIR or ~/.centralu */
|
|
96
|
+
export function serveDataDir(env, home) {
|
|
97
|
+
return env.CC_DATA_DIR || join(home, '.centralu')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The state, or null when there is none yet or it cannot be read as one */
|
|
101
|
+
export function readServeState(dataDir) {
|
|
102
|
+
let raw
|
|
103
|
+
try {
|
|
104
|
+
raw = readFileSync(join(dataDir, SERVE_STATE_FILE), 'utf8')
|
|
105
|
+
} catch {
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const v = JSON.parse(raw)
|
|
110
|
+
if (typeof v?.token !== 'string' || v.token.length < 32) return null
|
|
111
|
+
return { token: v.token, port: Number.isInteger(v.port) && v.port >= 1 && v.port <= 65535 ? v.port : null }
|
|
112
|
+
} catch {
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The state, created on first use. Never replaces a token that is already there.
|
|
119
|
+
*
|
|
120
|
+
* Created with `wx`, so two first runs at the same moment (a `serve` and a `--connection`) cannot
|
|
121
|
+
* each write a different token: the second one fails to create and reads the first one's. A file
|
|
122
|
+
* that exists but cannot be read as state is refused rather than overwritten: it may be a token
|
|
123
|
+
* the client already holds, and a silent new one would lock that client out with "bad token".
|
|
124
|
+
*/
|
|
125
|
+
export function ensureServeState(dataDir, platform = process.platform) {
|
|
126
|
+
mkdirSync(dataDir, { recursive: true })
|
|
127
|
+
const file = join(dataDir, SERVE_STATE_FILE)
|
|
128
|
+
if (!existsSync(file)) {
|
|
129
|
+
const token = randomBytes(32).toString('base64url')
|
|
130
|
+
try {
|
|
131
|
+
writePrivate(file, { v: 1, token, port: null }, 'wx')
|
|
132
|
+
} catch (e) {
|
|
133
|
+
if (e?.code !== 'EEXIST') throw e
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const state = readServeState(dataDir)
|
|
137
|
+
if (!state) throw new Error(`${file} exists but is not a serve state file. Move it aside and run again.`)
|
|
138
|
+
keepPrivate(file, platform)
|
|
139
|
+
return state
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Writes the port `serve` listened on, keeping the token. Atomic: a temp file renamed over it */
|
|
143
|
+
export function recordServePort(dataDir, port, platform = process.platform) {
|
|
144
|
+
const file = join(dataDir, SERVE_STATE_FILE)
|
|
145
|
+
const state = readServeState(dataDir)
|
|
146
|
+
if (!state) throw new Error(`${file} is missing or unreadable`)
|
|
147
|
+
if (state.port === port) return
|
|
148
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
149
|
+
writePrivate(tmp, { v: 1, token: state.token, port }, 'wx')
|
|
150
|
+
renameSync(tmp, file)
|
|
151
|
+
keepPrivate(file, platform)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A new token, keeping the port. Only when the person asks (`--rotate-token`): every client that
|
|
156
|
+
* stored the old one has to fetch the new one with `--connection`, and a serve already running
|
|
157
|
+
* keeps answering to the old one until it restarts.
|
|
158
|
+
*/
|
|
159
|
+
export function rotateServeToken(dataDir, platform = process.platform) {
|
|
160
|
+
const before = ensureServeState(dataDir, platform)
|
|
161
|
+
const file = join(dataDir, SERVE_STATE_FILE)
|
|
162
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
163
|
+
writePrivate(tmp, { v: 1, token: randomBytes(32).toString('base64url'), port: before.port }, 'wx')
|
|
164
|
+
renameSync(tmp, file)
|
|
165
|
+
keepPrivate(file, platform)
|
|
166
|
+
return readServeState(dataDir)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The launcher `serve` keeps at `<data folder>/bin/centralu` (`centralu.cmd` on Windows).
|
|
171
|
+
*
|
|
172
|
+
* The client runs `ssh -T -o BatchMode=yes <target> centralu …`, and a non-interactive SSH shell
|
|
173
|
+
* reads no profile: npm's global bin under nvm, fnm, volta or `~/.npm-global` is not on its PATH,
|
|
174
|
+
* and often neither is `node`. So the plain name fails with exit 127, and the person, who just ran
|
|
175
|
+
* `centralu serve` in their own shell, sees no reason why. This file names both by absolute path,
|
|
176
|
+
* so `~/.centralu/bin/centralu` works from any shell. It is rewritten whenever it differs from
|
|
177
|
+
* what this run would write, which follows a Node upgrade or a moved npm prefix on the next start.
|
|
178
|
+
*/
|
|
179
|
+
export function launcherScript(platform, execPath, cliPath) {
|
|
180
|
+
if (platform === 'win32') {
|
|
181
|
+
return `@echo off\r\nrem Written by \`centralu serve\`: reaches this install without npm's folder on PATH.\r\n"${execPath}" "${cliPath}" %*\r\n`
|
|
182
|
+
}
|
|
183
|
+
const q = (v) => `'${String(v).replaceAll("'", "'\\''")}'`
|
|
184
|
+
return (
|
|
185
|
+
'#!/bin/sh\n' +
|
|
186
|
+
"# Written by `centralu serve`: reaches this install from a shell whose PATH has neither npm's global\n" +
|
|
187
|
+
'# folder nor node (a non-interactive SSH session). Rewritten when node or the package moves.\n' +
|
|
188
|
+
`exec ${q(execPath)} ${q(cliPath)} "$@"\n`
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Writes the launcher if it is missing or out of date. Returns its path. Never fatal to the caller */
|
|
193
|
+
export function ensureLauncher(dataDir, { platform = process.platform, execPath = process.execPath, cliPath }) {
|
|
194
|
+
const file = join(dataDir, 'bin', platform === 'win32' ? 'centralu.cmd' : 'centralu')
|
|
195
|
+
const want = launcherScript(platform, execPath, cliPath)
|
|
196
|
+
let have = null
|
|
197
|
+
try {
|
|
198
|
+
have = readFileSync(file, 'utf8')
|
|
199
|
+
} catch {
|
|
200
|
+
// not there yet
|
|
201
|
+
}
|
|
202
|
+
if (have !== want) {
|
|
203
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
204
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
205
|
+
const fd = openSync(tmp, 'w', 0o755)
|
|
206
|
+
try {
|
|
207
|
+
writeSync(fd, want)
|
|
208
|
+
} finally {
|
|
209
|
+
closeSync(fd)
|
|
210
|
+
}
|
|
211
|
+
renameSync(tmp, file)
|
|
212
|
+
}
|
|
213
|
+
if (platform !== 'win32' && (statSync(file).mode & 0o777) !== 0o755) chmodSync(file, 0o755)
|
|
214
|
+
return file
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** ensureLauncher, with a failure said on stderr instead of thrown: the launcher is a convenience */
|
|
218
|
+
function keepLauncher(dataDir, cliPath) {
|
|
219
|
+
if (!cliPath) return
|
|
220
|
+
try {
|
|
221
|
+
ensureLauncher(dataDir, { cliPath })
|
|
222
|
+
} catch (e) {
|
|
223
|
+
say(`could not write the launcher in ${join(dataDir, 'bin')}: ${e.message}`)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Created 0600 from the first byte, not chmod-ed after: a file written with the default mode and
|
|
229
|
+
* narrowed afterwards is readable by everyone on the machine for the moment in between.
|
|
230
|
+
*/
|
|
231
|
+
function writePrivate(file, value, flag) {
|
|
232
|
+
const fd = openSync(file, flag, 0o600)
|
|
233
|
+
try {
|
|
234
|
+
writeSync(fd, `${JSON.stringify(value)}\n`)
|
|
235
|
+
} finally {
|
|
236
|
+
closeSync(fd)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 0600 again on every use. A copy, a restore from backup or an editor that rewrites the file can
|
|
242
|
+
* leave it readable by the group; the token in it is the key to every RPC. Windows has no mode
|
|
243
|
+
* bits to set (the profile folder's ACL is what protects it there).
|
|
244
|
+
*/
|
|
245
|
+
function keepPrivate(file, platform) {
|
|
246
|
+
if (platform === 'win32') return
|
|
247
|
+
if ((statSync(file).mode & 0o777) !== 0o600) chmodSync(file, 0o600)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The protocol a host folder speaks, from the `bundle-info.json` the bundler writes next to
|
|
252
|
+
* `main.mjs`. For a host run from source (`CENTRALU_HOST_ENTRY=…/src/main.ts`) there is no bundle
|
|
253
|
+
* info, so the constant is read where the bundler reads it. null when neither is there.
|
|
254
|
+
*/
|
|
255
|
+
export function protocolVersionOf(entry) {
|
|
256
|
+
try {
|
|
257
|
+
const v = JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).protocolVersion
|
|
258
|
+
if (Number.isInteger(v)) return v
|
|
259
|
+
} catch {
|
|
260
|
+
// not a bundle; try the source below
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const src = readFileSync(join(dirname(entry), '..', '..', 'protocol', 'src', 'envelope.ts'), 'utf8')
|
|
264
|
+
const m = /export const PROTOCOL_VERSION = (\d+)/.exec(src)
|
|
265
|
+
if (m) return Number(m[1])
|
|
266
|
+
} catch {
|
|
267
|
+
// nothing to read
|
|
268
|
+
}
|
|
269
|
+
return null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The `--connection` answer. The shape is the client's interface (docs/agent-host.md §4.7) */
|
|
273
|
+
export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning }) {
|
|
274
|
+
return JSON.stringify({ v: 1, port, token, version, protocolVersion, dataDir, hostRunning })
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The host's environment.
|
|
279
|
+
*
|
|
280
|
+
* - The token travels in `CC_HOST_TOKEN`, not `--token`: arguments are readable by every user on
|
|
281
|
+
* the machine (`ps`), a process's environment only by its owner. The host deletes it after
|
|
282
|
+
* reading, so nothing it spawns inherits it.
|
|
283
|
+
* - `CC_HOST_SOURCE` is the record `hello_ok.build` is made from (keeper-link.ts); without it a
|
|
284
|
+
* client could not see which version the host it reached is.
|
|
285
|
+
* - Headless: no `DISPLAY` / `WAYLAND_DISPLAY`. Nothing the host or an agent starts gets a screen
|
|
286
|
+
* to open a window or a keyring prompt on, so a tool that would ask falls back to its file store
|
|
287
|
+
* 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.
|
|
289
|
+
*/
|
|
290
|
+
export function hostEnv(env, { token, dataDir, version, hostDir }) {
|
|
291
|
+
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]
|
|
293
|
+
out.CC_DATA_DIR = dataDir
|
|
294
|
+
out.CC_HOST_TOKEN = token
|
|
295
|
+
out.CC_HOST_SOURCE = JSON.stringify({ version, bundlePath: hostDir })
|
|
296
|
+
return out
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* How to start the host.
|
|
301
|
+
*
|
|
302
|
+
* `--watch-parent` with stdin a pipe: if this launcher dies however it dies (SIGKILL included), the
|
|
303
|
+
* pipe closes and the host shuts down with its children, instead of living on unsupervised.
|
|
304
|
+
* `detached` on POSIX puts the host in its own process group, so the terminal's Ctrl+C reaches
|
|
305
|
+
* only this launcher, which passes one SIGINT to the host alone; the host then stops its own
|
|
306
|
+
* children in order. Without it the terminal would signal every agent at the same time as the
|
|
307
|
+
* host. Not on Windows: there `detached` means a new console, and Ctrl+C reaches the host through
|
|
308
|
+
* the console it shares with this launcher.
|
|
309
|
+
*/
|
|
310
|
+
export function hostCommand(execPath, entry, { port, dataDir, platform }) {
|
|
311
|
+
return {
|
|
312
|
+
command: execPath,
|
|
313
|
+
args: [
|
|
314
|
+
// A source entry (development, tests) needs the TypeScript loader; a bundle does not.
|
|
315
|
+
...(entry.endsWith('.ts') ? ['--import', 'tsx'] : []),
|
|
316
|
+
entry,
|
|
317
|
+
'--port',
|
|
318
|
+
String(port),
|
|
319
|
+
'--db',
|
|
320
|
+
join(dataDir, 'store.db'),
|
|
321
|
+
'--watch-parent',
|
|
322
|
+
],
|
|
323
|
+
detached: platform !== 'win32',
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The port from the host's ready line (`{"ready":true,"port":…,"token":…}`), null for any other line */
|
|
328
|
+
export function readyPort(line) {
|
|
329
|
+
try {
|
|
330
|
+
const v = JSON.parse(line)
|
|
331
|
+
return v?.ready === true && Number.isInteger(v.port) ? v.port : null
|
|
332
|
+
} catch {
|
|
333
|
+
return null
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The protocol a host's `version_mismatch` refusal names, from either wording: this one's
|
|
339
|
+
* ("this host speaks protocol 2") or the one before it ("server 2, client 1"). null if neither.
|
|
340
|
+
*/
|
|
341
|
+
export function mismatchServerVersion(message) {
|
|
342
|
+
const m = /host speaks protocol (\d+)|\bserver (\d+)/.exec(String(message ?? ''))
|
|
343
|
+
return m ? Number(m[1] ?? m[2]) : null
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Whether a host that holds this token answers on the port: a real hello, not a TCP connect, so a
|
|
348
|
+
* stranger's program on the same port does not count, and the answer carries the running host's
|
|
349
|
+
* own protocol and version (which differ from the installed package's after an `npm i -g` the
|
|
350
|
+
* host was not restarted for).
|
|
351
|
+
*
|
|
352
|
+
* `{ running, protocolVersion?, version? }`. Node 22.4+ has a global WebSocket; on an older 22
|
|
353
|
+
* this falls back to a TCP connect and reports only `running`.
|
|
354
|
+
*/
|
|
355
|
+
export function probeHost({ port, token, protocolVersion, timeoutMs = 2000, WebSocketImpl = globalThis.WebSocket }) {
|
|
356
|
+
if (typeof WebSocketImpl !== 'function') return tcpProbe(port, timeoutMs)
|
|
357
|
+
return new Promise((resolve) => {
|
|
358
|
+
let ws = null
|
|
359
|
+
let settled = false
|
|
360
|
+
const done = (result) => {
|
|
361
|
+
if (settled) return
|
|
362
|
+
settled = true
|
|
363
|
+
clearTimeout(timer)
|
|
364
|
+
try {
|
|
365
|
+
ws?.close()
|
|
366
|
+
} catch {
|
|
367
|
+
// already closed
|
|
368
|
+
}
|
|
369
|
+
resolve(result)
|
|
370
|
+
}
|
|
371
|
+
const timer = setTimeout(() => done({ running: false }), timeoutMs)
|
|
372
|
+
try {
|
|
373
|
+
ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
|
|
374
|
+
} catch {
|
|
375
|
+
done({ running: false })
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
ws.onopen = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
|
|
379
|
+
ws.onmessage = (ev) => {
|
|
380
|
+
let m
|
|
381
|
+
try {
|
|
382
|
+
m = JSON.parse(String(ev.data))
|
|
383
|
+
} catch {
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
if (m?.kind === 'hello_ok') {
|
|
387
|
+
done({
|
|
388
|
+
running: true,
|
|
389
|
+
protocolVersion: Number.isInteger(m.protocolVersion) ? m.protocolVersion : undefined,
|
|
390
|
+
version: typeof m.build?.version === 'string' ? m.build.version : undefined,
|
|
391
|
+
})
|
|
392
|
+
} else if (m?.kind === 'res' && m.ok === false && m.error?.code === 'version_mismatch') {
|
|
393
|
+
// The token was right (it is checked first), so this is our host, on another protocol
|
|
394
|
+
done({ running: true, protocolVersion: mismatchServerVersion(m.error.message) ?? undefined })
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
ws.onerror = () => done({ running: false })
|
|
398
|
+
ws.onclose = () => done({ running: false })
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function tcpProbe(port, timeoutMs) {
|
|
403
|
+
return new Promise((resolve) => {
|
|
404
|
+
const s = connect({ host: '127.0.0.1', port })
|
|
405
|
+
const done = (running) => {
|
|
406
|
+
s.destroy()
|
|
407
|
+
resolve({ running })
|
|
408
|
+
}
|
|
409
|
+
s.setTimeout(timeoutMs, () => done(false))
|
|
410
|
+
s.once('connect', () => done(true))
|
|
411
|
+
s.once('error', () => done(false))
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Who `host.lock` names as the data folder's owner, or null */
|
|
416
|
+
export function lockHolderPid(dataDir) {
|
|
417
|
+
try {
|
|
418
|
+
const text = readFileSync(join(dataDir, 'host.lock'), 'utf8').trim()
|
|
419
|
+
const pid = /^\d+$/.test(text) ? Number(text) : JSON.parse(text)?.pid
|
|
420
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null
|
|
421
|
+
} catch {
|
|
422
|
+
return null
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function isAlive(pid) {
|
|
427
|
+
try {
|
|
428
|
+
process.kill(pid, 0)
|
|
429
|
+
return true
|
|
430
|
+
} catch (e) {
|
|
431
|
+
return e?.code === 'EPERM'
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Writes a line to stderr, prefixed so it reads apart from the host's own log lines */
|
|
436
|
+
function say(line) {
|
|
437
|
+
process.stderr.write(`[centralu serve] ${line}\n`)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* `centralu serve --connection`. stdout carries exactly one line, the JSON; everything else goes to
|
|
442
|
+
* stderr. Exit 1 when there is no host to describe.
|
|
443
|
+
*/
|
|
444
|
+
export async function printConnection({ env, home, entry, version, cliPath }) {
|
|
445
|
+
const dataDir = serveDataDir(env, home)
|
|
446
|
+
const state = ensureServeState(dataDir)
|
|
447
|
+
keepLauncher(dataDir, cliPath)
|
|
448
|
+
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
449
|
+
const installed = protocolVersionOf(entry)
|
|
450
|
+
const probe = await probeHost({ port, token: state.token, protocolVersion: installed })
|
|
451
|
+
const protocolVersion = (probe.running ? probe.protocolVersion : undefined) ?? installed
|
|
452
|
+
if (protocolVersion === null) {
|
|
453
|
+
say(`cannot read the protocol version of the host at ${entry}`)
|
|
454
|
+
return 1
|
|
455
|
+
}
|
|
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
|
+
)
|
|
466
|
+
return 0
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* `centralu serve`. Resolves with the exit code once the host has exited.
|
|
471
|
+
*
|
|
472
|
+
* The host's stdout is read here and never passed on: its ready line carries the token, which is
|
|
473
|
+
* printed nowhere but `--connection`. Every other line the host writes to stdout (a lock conflict,
|
|
474
|
+
* a store from a newer build) it also writes to stderr, which is passed through untouched.
|
|
475
|
+
*/
|
|
476
|
+
export async function runServe({ env, home, entry, version, cliPath, port: askedPort, platform = process.platform, execPath = process.execPath }) {
|
|
477
|
+
const dataDir = serveDataDir(env, home)
|
|
478
|
+
const state = ensureServeState(dataDir, platform)
|
|
479
|
+
keepLauncher(dataDir, cliPath)
|
|
480
|
+
const port = askedPort ?? state.port ?? DEFAULT_SERVE_PORT
|
|
481
|
+
const protocolVersion = protocolVersionOf(entry)
|
|
482
|
+
|
|
483
|
+
// A serve that is already up answers on the port it recorded; say so before starting a host that
|
|
484
|
+
// would only fail on the data folder's lock with a less specific message.
|
|
485
|
+
const recorded = state.port ?? DEFAULT_SERVE_PORT
|
|
486
|
+
const existing = await probeHost({ port: recorded, token: state.token, protocolVersion })
|
|
487
|
+
if (existing.running) {
|
|
488
|
+
const pid = lockHolderPid(dataDir)
|
|
489
|
+
say(`centralu serve is already running for ${dataDir} on 127.0.0.1:${recorded}${pid ? ` (pid ${pid})` : ''}. Not starting a second one.`)
|
|
490
|
+
return 1
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const plan = hostCommand(execPath, entry, { port, dataDir, platform })
|
|
494
|
+
const child = spawn(plan.command, plan.args, {
|
|
495
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
496
|
+
env: hostEnv(env, { token: state.token, dataDir, version, hostDir: dirname(entry) }),
|
|
497
|
+
detached: plan.detached,
|
|
498
|
+
windowsHide: true,
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
let ready = false
|
|
502
|
+
createInterface({ input: child.stdout }).on('line', (line) => {
|
|
503
|
+
const p = readyPort(line)
|
|
504
|
+
if (p === null || ready) return
|
|
505
|
+
ready = true
|
|
506
|
+
try {
|
|
507
|
+
recordServePort(dataDir, p, platform)
|
|
508
|
+
} catch (e) {
|
|
509
|
+
say(`could not record the port in ${join(dataDir, SERVE_STATE_FILE)}: ${e.message}`)
|
|
510
|
+
}
|
|
511
|
+
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`)
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
/*
|
|
516
|
+
* One signal to the host alone, whatever reached this launcher. SIGHUP (the terminal closed) is
|
|
517
|
+
* passed as SIGTERM: the host has no SIGHUP handler, and Node's default for it is to exit on the
|
|
518
|
+
* spot, skipping the shutdown that stops the agents. A second signal changes nothing on the host
|
|
519
|
+
* side (its shutdown runs once), so forwarding every one is safe.
|
|
520
|
+
*/
|
|
521
|
+
const forward = (sig) => {
|
|
522
|
+
if (child.exitCode !== null || child.signalCode !== null) return
|
|
523
|
+
if (platform === 'win32') return // the host shares the console and received Ctrl+C itself
|
|
524
|
+
try {
|
|
525
|
+
child.kill(sig === 'SIGHUP' ? 'SIGTERM' : sig)
|
|
526
|
+
} catch {
|
|
527
|
+
// already gone
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const signals = platform === 'win32' ? ['SIGINT'] : ['SIGINT', 'SIGTERM', 'SIGHUP']
|
|
531
|
+
for (const s of signals) process.on(s, forward)
|
|
532
|
+
|
|
533
|
+
return await new Promise((resolve) => {
|
|
534
|
+
child.on('error', (e) => {
|
|
535
|
+
say(`could not start the host (${plan.command}): ${e.message}`)
|
|
536
|
+
resolve(1)
|
|
537
|
+
})
|
|
538
|
+
child.on('exit', (code, signal) => {
|
|
539
|
+
for (const s of signals) process.off(s, forward)
|
|
540
|
+
if (!ready && code !== 0) {
|
|
541
|
+
const pid = lockHolderPid(dataDir)
|
|
542
|
+
if (pid && pid !== child.pid && isAlive(pid)) {
|
|
543
|
+
say(
|
|
544
|
+
`the host did not start. ${dataDir} belongs to another Centralu host (pid ${pid}), ` +
|
|
545
|
+
'probably the Centralu app on this machine. Quit it, or point CC_DATA_DIR at another folder.',
|
|
546
|
+
)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
resolve(code ?? (signal ? 1 : 0))
|
|
550
|
+
})
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** `centralu serve --rotate-token` */
|
|
555
|
+
export function rotateToken({ env, home }) {
|
|
556
|
+
const dataDir = serveDataDir(env, home)
|
|
557
|
+
rotateServeToken(dataDir)
|
|
558
|
+
say(`replaced the token in ${join(dataDir, SERVE_STATE_FILE)}.`)
|
|
559
|
+
say('A serve that is running keeps the old one until it restarts; clients read the new one with --connection.')
|
|
560
|
+
return 0
|
|
561
|
+
}
|
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.12",
|
|
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.12",
|
|
41
|
+
"@centralu/linux-arm64": "0.1.0-beta.12",
|
|
42
|
+
"@centralu/linux-x64": "0.1.0-beta.12",
|
|
43
|
+
"@centralu/win32-x64": "0.1.0-beta.12"
|
|
44
44
|
}
|
|
45
45
|
}
|