centralu 0.1.0-beta.27 → 0.1.0-beta.29
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/platform.mjs +8 -0
- package/bin/serve-files.mjs +23 -8
- package/bin/serve-host.mjs +29 -6
- package/bin/serve-install.mjs +19 -1
- package/bin/serve-run.mjs +5 -2
- package/bin/serve-util.mjs +36 -3
- package/bin/serve.mjs +1 -1
- package/package.json +5 -5
package/bin/platform.mjs
CHANGED
|
@@ -42,6 +42,14 @@ export function targetFor(platform, arch) {
|
|
|
42
42
|
return TARGETS[`${platform}-${arch}`] ?? null
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Whether replacing a file by rename fails while another program has the target open (a reader, a virus scanner):
|
|
47
|
+
* Windows, which then answers EPERM, EBUSY or EACCES for a moment. Elsewhere a rename over an open file just works.
|
|
48
|
+
*/
|
|
49
|
+
export function renameWaitsForReaders(platform = process.platform) {
|
|
50
|
+
return platform === 'win32'
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
/**
|
|
46
54
|
* The file that proves the artifact is there and is what gets started.
|
|
47
55
|
*
|
package/bin/serve-files.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { execFileSync } from 'node:child_process'
|
|
|
7
7
|
import { randomBytes } from 'node:crypto'
|
|
8
8
|
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeSync, closeSync } from 'node:fs'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
|
-
import { isAlive, replaceFile } from './serve-util.mjs'
|
|
10
|
+
import { isAlive, replaceFile, sleepSync } from './serve-util.mjs'
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* The file in the data folder that holds the token and the port, mode 0600.
|
|
@@ -30,14 +30,29 @@ export function serveDataDir(env, home) {
|
|
|
30
30
|
return env.CC_DATA_DIR || join(home, '.centralu')
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The state, or null when there is none yet or it cannot be read as one. A file that is refused (Windows, while a
|
|
35
|
+
* writer or a scanner has it) or does not parse is read again a few times before it counts as unreadable: a
|
|
36
|
+
* `--detach` waiting for the port reads it while the launcher replaces it (#1041).
|
|
37
|
+
*/
|
|
38
|
+
export function readServeState(dataDir, { tries = 5, sleep = sleepSync } = {}) {
|
|
39
|
+
const file = join(dataDir, SERVE_STATE_FILE)
|
|
40
|
+
for (let i = 1; ; i++) {
|
|
41
|
+
let raw
|
|
42
|
+
try {
|
|
43
|
+
raw = readFileSync(file, 'utf8')
|
|
44
|
+
} catch (e) {
|
|
45
|
+
if (e?.code === 'ENOENT' || i >= tries) return null
|
|
46
|
+
sleep(10 * i)
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
const state = parseServeState(raw)
|
|
50
|
+
if (state || i >= tries) return state
|
|
51
|
+
sleep(10 * i)
|
|
40
52
|
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseServeState(raw) {
|
|
41
56
|
try {
|
|
42
57
|
const v = JSON.parse(raw)
|
|
43
58
|
if (typeof v?.token !== 'string' || v.token.length < 32) return null
|
package/bin/serve-host.mjs
CHANGED
|
@@ -54,9 +54,12 @@ export function connectionLine({ port, token, version, protocolVersion, dataDir,
|
|
|
54
54
|
/**
|
|
55
55
|
* The host's environment.
|
|
56
56
|
*
|
|
57
|
-
* - The token
|
|
58
|
-
*
|
|
59
|
-
*
|
|
57
|
+
* - The token is in neither: arguments are readable by every user on the machine (`ps`), and a
|
|
58
|
+
* process's start environment by every process of its user for as long as it runs (`ps eww`,
|
|
59
|
+
* #502). `CC_HOST_TOKEN_ON_STDIN=1` tells the host it comes as the first line on its stdin
|
|
60
|
+
* (`tokenLine`, #538), which is a pipe to this launcher anyway (`--watch-parent`). A host bundle
|
|
61
|
+
* from before #538 reads only `CC_HOST_TOKEN`: given `legacyToken` (`takesTokenOnStdin` said no),
|
|
62
|
+
* it gets the token there, as before.
|
|
60
63
|
* - `CC_HOST_SOURCE` is the record `hello_ok.build` is made from (keeper-link.ts); without it a
|
|
61
64
|
* client could not see which version the host it reached is.
|
|
62
65
|
* - Headless: no `DISPLAY` / `WAYLAND_DISPLAY`. Nothing the host or an agent starts gets a screen
|
|
@@ -66,17 +69,37 @@ export function connectionLine({ port, token, version, protocolVersion, dataDir,
|
|
|
66
69
|
* `CENTRALU_MANAGED`, which is the launcher's business and would otherwise reach every agent.
|
|
67
70
|
* - `CC_SERVE=1` lets the host take `host.stop` (`--stop`); a host the app runs refuses it.
|
|
68
71
|
*/
|
|
69
|
-
export function hostEnv(env, {
|
|
72
|
+
export function hostEnv(env, { dataDir, version, hostDir, legacyToken }) {
|
|
70
73
|
const out = { ...env }
|
|
71
|
-
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]
|
|
74
|
+
for (const k of ['DISPLAY', 'WAYLAND_DISPLAY', 'CC_KEEPER', 'CC_FRONT_DOOR', 'CC_DEV', 'CC_HOST_TOKEN', 'CC_HOST_TOKEN_ON_STDIN', 'CC_HOST_SOURCE', 'CENTRALU_MANAGED']) delete out[k]
|
|
72
75
|
out.CC_DATA_DIR = dataDir
|
|
73
76
|
// The host `host.stop` may end (`centralu serve --stop`): nothing else stops it in order on Windows
|
|
74
77
|
out.CC_SERVE = '1'
|
|
75
|
-
out.
|
|
78
|
+
if (legacyToken === undefined) out.CC_HOST_TOKEN_ON_STDIN = '1'
|
|
79
|
+
else out.CC_HOST_TOKEN = legacyToken
|
|
76
80
|
out.CC_HOST_SOURCE = JSON.stringify({ version, bundlePath: hostDir })
|
|
77
81
|
return out
|
|
78
82
|
}
|
|
79
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Whether the host at `entry` reads its token from stdin (#538): a bundle says so in `bundle-info.json`
|
|
86
|
+
* (`tokenOnStdin`), as the keeper reads it; a host run from source always does. `CENTRALU_HOST_ENTRY` can point at an
|
|
87
|
+
* older bundle, which would otherwise get no token and refuse every hello.
|
|
88
|
+
*/
|
|
89
|
+
export function takesTokenOnStdin(entry) {
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).tokenOnStdin === 1
|
|
92
|
+
} catch {
|
|
93
|
+
// not a bundle: the source, which reads it
|
|
94
|
+
}
|
|
95
|
+
return !existsSync(join(dirname(entry), 'bundle-info.json'))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The first line on the host's stdin: the token (#538, the host's `readHandedToken`) */
|
|
99
|
+
export function tokenLine(token) {
|
|
100
|
+
return `${JSON.stringify({ token })}\n`
|
|
101
|
+
}
|
|
102
|
+
|
|
80
103
|
/**
|
|
81
104
|
* How to start the host.
|
|
82
105
|
*
|
package/bin/serve-install.mjs
CHANGED
|
@@ -114,6 +114,21 @@ export function parsePointer(text) {
|
|
|
114
114
|
return { version: m[1], node: m[2] }
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Where a managed version came from, from its `install.json`: `{ kind: 'dev', commit, dirty }` for a dev build put
|
|
119
|
+
* there from a git commit (`pnpm remote-dev`, #1036), null for a release (or anything unreadable). The hub shows it
|
|
120
|
+
* on the machine's row, so a build no release produced is never mistaken for one.
|
|
121
|
+
*/
|
|
122
|
+
export function installSource(versionDir) {
|
|
123
|
+
try {
|
|
124
|
+
const s = JSON.parse(readFileSync(join(versionDir, 'install.json'), 'utf8')).source
|
|
125
|
+
if (s?.kind === 'dev' && typeof s.commit === 'string' && /^[0-9a-f]{7,64}$/.test(s.commit)) return { kind: 'dev', commit: s.commit, dirty: s.dirty === true }
|
|
126
|
+
} catch {
|
|
127
|
+
// no install.json, or not one this reads
|
|
128
|
+
}
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
|
|
117
132
|
/**
|
|
118
133
|
* The connection line's `install` field (docs/plans/remote-hub.md §10.5): whether this command was
|
|
119
134
|
* started by the managed launcher, the managed install's `current` and `previous`, and the Node
|
|
@@ -122,11 +137,14 @@ export function parsePointer(text) {
|
|
|
122
137
|
export function installInfo(dataDir, env, nodeVersion = process.versions.node) {
|
|
123
138
|
const l = remoteLayout(dataDir)
|
|
124
139
|
const read = (file) => {
|
|
140
|
+
let p
|
|
125
141
|
try {
|
|
126
|
-
|
|
142
|
+
p = parsePointer(readFileSync(file, 'utf8'))
|
|
127
143
|
} catch {
|
|
128
144
|
return null
|
|
129
145
|
}
|
|
146
|
+
const source = p && installSource(join(l.versions, p.version))
|
|
147
|
+
return p && source ? { ...p, source } : p
|
|
130
148
|
}
|
|
131
149
|
return { managed: env.CENTRALU_MANAGED === '1', current: read(l.current), previous: read(l.previous), node: nodeVersion }
|
|
132
150
|
}
|
package/bin/serve-run.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { isAlive, say } from './serve-util.mjs'
|
|
|
7
7
|
import { SERVE_STATE_FILE, SERVE_PID_FILE, serveDataDir, ensureServeState, recordServePort, rotateServeToken, heldHere, lockHolderPid, removeServePid } from './serve-files.mjs'
|
|
8
8
|
import { DEFAULT_SERVE_PORT, SERVE_PORT_TRIES, chooseServePort, listensHere, windowsListening } from './serve-port.mjs'
|
|
9
9
|
import { keepLauncher, installInfo } from './serve-install.mjs'
|
|
10
|
-
import { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, hostProvesOf, probeHost } from './serve-host.mjs'
|
|
10
|
+
import { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, hostProvesOf, probeHost, takesTokenOnStdin, tokenLine } from './serve-host.mjs'
|
|
11
11
|
import { autostartInfo } from './serve-autostart.mjs'
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -97,12 +97,15 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
const plan = hostCommand(execPath, entry, { port, dataDir, platform })
|
|
100
|
+
const onStdin = takesTokenOnStdin(entry)
|
|
100
101
|
const child = spawn(plan.command, plan.args, {
|
|
101
102
|
stdio: ['pipe', 'pipe', logFd === null ? 'inherit' : 'pipe'],
|
|
102
|
-
env: hostEnv(env, {
|
|
103
|
+
env: hostEnv(env, { dataDir, version, hostDir: dirname(entry), ...(onStdin ? {} : { legacyToken: state.token }) }),
|
|
103
104
|
detached: plan.detached,
|
|
104
105
|
windowsHide: true,
|
|
105
106
|
})
|
|
107
|
+
// The token, before anything else on the pipe the host watches (#538); stdin stays open for --watch-parent
|
|
108
|
+
if (onStdin) child.stdin.write(tokenLine(state.token))
|
|
106
109
|
|
|
107
110
|
let ready = false
|
|
108
111
|
if (logFd !== null) {
|
package/bin/serve-util.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* output, a process check, and waiting.
|
|
4
4
|
*/
|
|
5
5
|
import { spawn } from 'node:child_process'
|
|
6
|
-
import { closeSync, openSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { closeSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { renameWaitsForReaders } from './platform.mjs'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Where `say` writes: stderr, except in a detached launcher, which nobody reads the stderr of and
|
|
@@ -31,12 +32,39 @@ export function sayTo(write) {
|
|
|
31
32
|
sink = write
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/** Blocks this thread for `ms`, for the synchronous callers that cannot await (a rename that is retried) */
|
|
36
|
+
export function sleepSync(ms) {
|
|
37
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What a rename over a file says on Windows while another handle has the target open (a reader, a virus scanner) */
|
|
41
|
+
const BUSY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES'])
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `rename`, tried again for up to `budgetMs` while the system refuses it because someone has the target open (#1041;
|
|
45
|
+
* `retry`: Windows only, `renameWaitsForReaders`). With `retry` off, and for any other error, it is one try. Backs off 10, 20, 40 ... 200 ms between tries; the last
|
|
46
|
+
* error is thrown when the budget is spent.
|
|
47
|
+
*/
|
|
48
|
+
export function renameRetrying(from, to, { retry = renameWaitsForReaders(), rename = renameSync, sleep = sleepSync, budgetMs = 5_000, now = Date.now } = {}) {
|
|
49
|
+
const t0 = now()
|
|
50
|
+
for (let delay = 10; ; delay = Math.min(delay * 2, 200)) {
|
|
51
|
+
try {
|
|
52
|
+
return rename(from, to)
|
|
53
|
+
} catch (e) {
|
|
54
|
+
if (!retry || !BUSY_CODES.has(e?.code) || now() - t0 + delay > budgetMs) throw e
|
|
55
|
+
}
|
|
56
|
+
sleep(delay)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
34
60
|
/**
|
|
35
61
|
* Replaces `file` with `text` at once: written to a temporary file beside it, then renamed over it, so a reader finds
|
|
36
62
|
* the old file or the new one and never half of one. `mode` and `flag` are the temporary file's: a private file is
|
|
37
63
|
* created with its mode from the first byte, and with `wx`, never written into a file that is already there.
|
|
64
|
+
* On Windows a rename fails while a reader has the target open, so it is retried (`renameRetrying`); when it still
|
|
65
|
+
* fails, the temporary file is removed and the error thrown, the old file untouched.
|
|
38
66
|
*/
|
|
39
|
-
export function replaceFile(file, text, { mode = 0o666, flag = 'w' } = {}) {
|
|
67
|
+
export function replaceFile(file, text, { mode = 0o666, flag = 'w', ...rename } = {}) {
|
|
40
68
|
const tmp = `${file}.${process.pid}.tmp`
|
|
41
69
|
const fd = openSync(tmp, flag, mode)
|
|
42
70
|
try {
|
|
@@ -44,7 +72,12 @@ export function replaceFile(file, text, { mode = 0o666, flag = 'w' } = {}) {
|
|
|
44
72
|
} finally {
|
|
45
73
|
closeSync(fd)
|
|
46
74
|
}
|
|
47
|
-
|
|
75
|
+
try {
|
|
76
|
+
renameRetrying(tmp, file, rename)
|
|
77
|
+
} catch (e) {
|
|
78
|
+
rmSync(tmp, { force: true })
|
|
79
|
+
throw e
|
|
80
|
+
}
|
|
48
81
|
}
|
|
49
82
|
|
|
50
83
|
export function runCaptured(command, args, env) {
|
package/bin/serve.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export { detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, wi
|
|
|
29
29
|
export { DEFAULT_SERVE_PORT, SERVE_PORT_TRIES, chooseServePort, listensHere, parseNetstatListening } from './serve-port.mjs'
|
|
30
30
|
export { SERVE_HELP, AUTOSTART_ACTIONS, parseServeArgs } from './serve-args.mjs'
|
|
31
31
|
export { launcherScript, ensureLauncher, remoteLayout, parsePointer, installInfo, managedLauncherScript } from './serve-install.mjs'
|
|
32
|
-
export { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, mismatchServerVersion, hostProvesOf, probeHost, askHostToStop } from './serve-host.mjs'
|
|
32
|
+
export { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, mismatchServerVersion, hostProvesOf, probeHost, askHostToStop, takesTokenOnStdin, tokenLine } from './serve-host.mjs'
|
|
33
33
|
export { AUTOSTART_FILE, AUTOSTART_ENV, autostartHow, autostartUnsupported, pathKey, autostartName, autostartLauncher, systemdUnit, taskActionScript, taskScript, parseTaskAnswer, readAutostartMark, autostartInfo, lingerMessage, runAutostart } from './serve-autostart.mjs'
|
|
34
34
|
export { printConnection, runServe, rotateToken } from './serve-run.mjs'
|
|
35
35
|
export { CARRIED_ENV, encodeChildSpec, decodeChildSpec, startDetached, runDetach, runDetachedChild, runStop } from './serve-detach.mjs'
|
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.29",
|
|
4
4
|
"description": "Run, watch and steer several Claude Code and Codex CLI sessions from one window",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "ijun17",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
"win32"
|
|
39
39
|
],
|
|
40
40
|
"optionalDependencies": {
|
|
41
|
-
"@centralu/darwin-arm64": "0.1.0-beta.
|
|
42
|
-
"@centralu/linux-arm64": "0.1.0-beta.
|
|
43
|
-
"@centralu/linux-x64": "0.1.0-beta.
|
|
44
|
-
"@centralu/win32-x64": "0.1.0-beta.
|
|
41
|
+
"@centralu/darwin-arm64": "0.1.0-beta.29",
|
|
42
|
+
"@centralu/linux-arm64": "0.1.0-beta.29",
|
|
43
|
+
"@centralu/linux-x64": "0.1.0-beta.29",
|
|
44
|
+
"@centralu/win32-x64": "0.1.0-beta.29"
|
|
45
45
|
}
|
|
46
46
|
}
|