centralu 0.1.0-beta.4 → 0.1.0-beta.41
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 +466 -117
- package/bin/node-floor.mjs +42 -0
- package/bin/platform.mjs +380 -0
- package/bin/semver.mjs +37 -4
- package/bin/serve-args.mjs +116 -0
- package/bin/serve-autostart.mjs +489 -0
- package/bin/serve-detach.mjs +289 -0
- package/bin/serve-files.mjs +299 -0
- package/bin/serve-handover.mjs +152 -0
- package/bin/serve-host.mjs +395 -0
- package/bin/serve-install.mjs +204 -0
- package/bin/serve-port.mjs +79 -0
- package/bin/serve-run.mjs +197 -0
- package/bin/serve-util.mjs +127 -0
- package/bin/serve-window.mjs +112 -0
- package/bin/serve-windows.mjs +171 -0
- package/bin/serve.mjs +37 -0
- package/bin/swap.mjs +48 -0
- package/package.json +10 -6
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `centralu serve --window` (#1223): what the desktop window on Windows runs in place of a host of its own when
|
|
3
|
+
* `centralu serve` is installed in its data folder (`start_plan`'s `Host::Serve` in the window). One host per data
|
|
4
|
+
* folder: a linked hub's serve holds this one, or will, so the window uses that host rather than start one the folder's
|
|
5
|
+
* lock refuses.
|
|
6
|
+
*/
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { readServeState } from './serve-files.mjs'
|
|
9
|
+
import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
|
|
10
|
+
import { hostProvesOf, probeHost, protocolVersionOf } from './serve-host.mjs'
|
|
11
|
+
import { serveFolder } from './serve-run.mjs'
|
|
12
|
+
import { runDetach } from './serve-detach.mjs'
|
|
13
|
+
import { startInSignIn } from './serve-autostart.mjs'
|
|
14
|
+
import { say, sleep } from './serve-util.mjs'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Makes sure the folder's serve host runs, then prints the window's ready line, `{"ready":true,"port","token","db"}`
|
|
18
|
+
* (the line a host the window starts prints), and stays until the window goes.
|
|
19
|
+
*
|
|
20
|
+
* - No host answers: one is started the way `--detach` starts it, in this Windows sign-in through the scheduled task,
|
|
21
|
+
* so it outlives the window and the hub's link finds it (`serve --connection`).
|
|
22
|
+
* - A host in a network logon answers (the one the hub's ssh started before anyone signed in): the window uses it now,
|
|
23
|
+
* and the sign-in task is started, whose serve moves it into this sign-in once nothing runs there
|
|
24
|
+
* (`moveIntoSignIn`, serve-handover.mjs).
|
|
25
|
+
* - stdin is the window (it runs this with stdin a pipe): when it ends, this exits 0 and leaves the host running,
|
|
26
|
+
* since it serves the hub's link too.
|
|
27
|
+
* - The host stops answering for `misses` polls in a row (the hand-over's gap, or it ended): this exits 1, and the
|
|
28
|
+
* window's supervisor starts this again, which finds the new host or starts one.
|
|
29
|
+
*
|
|
30
|
+
* Resolves with the exit code. `detach`, `signIn`, `pollMs`, `misses` and `stdin` are parameters for tests.
|
|
31
|
+
*/
|
|
32
|
+
export async function runWindow({
|
|
33
|
+
env,
|
|
34
|
+
home,
|
|
35
|
+
entry,
|
|
36
|
+
version,
|
|
37
|
+
cliPath,
|
|
38
|
+
platform = process.platform,
|
|
39
|
+
write = (line) => process.stdout.write(line),
|
|
40
|
+
stdin = process.stdin,
|
|
41
|
+
detach = runDetach,
|
|
42
|
+
signIn = startInSignIn,
|
|
43
|
+
pollMs = 5_000,
|
|
44
|
+
misses = 3,
|
|
45
|
+
}) {
|
|
46
|
+
const { dataDir } = serveFolder({ env, home, cliPath, platform })
|
|
47
|
+
const prove = hostProvesOf(entry)
|
|
48
|
+
const protocolVersion = protocolVersionOf(entry) ?? 0
|
|
49
|
+
const probe = () => {
|
|
50
|
+
const state = readServeState(dataDir)
|
|
51
|
+
return state ? probeHost({ port: state.port ?? DEFAULT_SERVE_PORT, token: state.token, protocolVersion, prove }) : Promise.resolve({ running: false })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let host = await probe()
|
|
55
|
+
if (!host.running) {
|
|
56
|
+
say(`no Centralu host runs for ${dataDir}: starting one in this Windows sign-in, which keeps running for a linked hub when the window closes`)
|
|
57
|
+
let answer = null
|
|
58
|
+
await detach({ env, home, entry, version, cliPath, platform, write: (line) => (answer = line) })
|
|
59
|
+
const d = parseLine(answer)
|
|
60
|
+
if (!d?.hostRunning) {
|
|
61
|
+
say(`could not start the host: ${d?.detach?.message ?? 'centralu serve --detach did not answer'}`)
|
|
62
|
+
return 1
|
|
63
|
+
}
|
|
64
|
+
host = await probe()
|
|
65
|
+
if (!host.running) {
|
|
66
|
+
say('the host started, then did not answer')
|
|
67
|
+
return 1
|
|
68
|
+
}
|
|
69
|
+
} else if (host.logon === 'network') {
|
|
70
|
+
const s = await signIn({ dataDir, env, home, platform }).catch((e) => ({ started: false, reason: 'error', message: e.message }))
|
|
71
|
+
say(
|
|
72
|
+
s.started
|
|
73
|
+
? 'the host runs in a network logon (started over ssh): this window uses it, and it moves into this sign-in once nothing runs there'
|
|
74
|
+
: `the host runs in a network logon (started over ssh), and could not be moved into this sign-in (${s.message ?? s.reason})`,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const state = readServeState(dataDir)
|
|
79
|
+
write(`${JSON.stringify({ ready: true, port: state.port ?? DEFAULT_SERVE_PORT, token: state.token, db: join(dataDir, 'store.db') })}\n`)
|
|
80
|
+
|
|
81
|
+
return await new Promise((resolve) => {
|
|
82
|
+
let done = false
|
|
83
|
+
const finish = (code) => {
|
|
84
|
+
if (done) return
|
|
85
|
+
done = true
|
|
86
|
+
resolve(code)
|
|
87
|
+
}
|
|
88
|
+
stdin.once('end', () => finish(0))
|
|
89
|
+
stdin.once('close', () => finish(0))
|
|
90
|
+
stdin.resume()
|
|
91
|
+
void (async () => {
|
|
92
|
+
let missed = 0
|
|
93
|
+
while (!done) {
|
|
94
|
+
await sleep(pollMs)
|
|
95
|
+
if (done) return
|
|
96
|
+
missed = (await probe()).running ? 0 : missed + 1
|
|
97
|
+
if (missed >= misses) {
|
|
98
|
+
say(`the host for ${dataDir} stopped answering; the window starts this again`)
|
|
99
|
+
finish(1)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
})()
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseLine(text) {
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(String(text ?? '').trim().split('\n').at(-1))
|
|
109
|
+
} catch {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows and WSL for `centralu serve`: which way a start outlives the ssh session, a Windows command
|
|
3
|
+
* line, the PowerShell that asks WMI to create a process, and the Windows programs reached by full path
|
|
4
|
+
* (from Windows itself, or through the drive a WSL distro mounts).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { systemRoot } from './platform.mjs'
|
|
8
|
+
import { runCaptured } from './serve-util.mjs'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How `--detach` starts the launcher so that it outlives the session that asked (measured,
|
|
12
|
+
* docs/plans/remote-hub.md §10.4):
|
|
13
|
+
*
|
|
14
|
+
* setsid posix: a new session, no terminal, nothing from the ssh session's process group. Once
|
|
15
|
+
* `--detach` returns, the launcher is reparented to init (the second fork of the usual
|
|
16
|
+
* double fork); it opens no terminal, so it can never get a controlling one back
|
|
17
|
+
* wmi Windows: `Win32_Process.Create`. Windows' OpenSSH ends every process of a session with
|
|
18
|
+
* it, `Start-Process` included; a process WMI creates is not part of the session
|
|
19
|
+
* wsl inside WSL: the same WMI call from the Windows side, creating `wsl.exe -d <distro>
|
|
20
|
+
* --exec <launcher>`. A `setsid` launcher inside the distro survives the session but not the
|
|
21
|
+
* distro, which WSL stops about 15 s after its last `wsl.exe` client exits; this
|
|
22
|
+
* `wsl.exe` is a client for as long as the host runs, and ending it ends the host
|
|
23
|
+
*/
|
|
24
|
+
export function detachHow(platform, env, interop = wslInterop) {
|
|
25
|
+
if (platform === 'win32') return 'wmi'
|
|
26
|
+
if (platform === 'linux' && env.WSL_DISTRO_NAME && interop()) return 'wsl'
|
|
27
|
+
return 'setsid'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function wslInterop() {
|
|
31
|
+
return existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') || existsSync('/proc/sys/fs/binfmt_misc/WSLInterop-late')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Which Windows logon this process runs in (#1224), from `whoami /groups` as the host reads its own (`logonFromGroups`
|
|
36
|
+
* in agent-host's os.ts): `interactive` (S-1-5-4), `network` (S-1-5-2), `batch`, `service`, or null when it cannot
|
|
37
|
+
* tell. By the SIDs, which read the same in every language
|
|
38
|
+
*/
|
|
39
|
+
export async function ownLogon(env, run = runCaptured) {
|
|
40
|
+
const r = await run(windowsTool('wmi', env, ['System32', 'whoami.exe']), ['/groups', '/fo', 'csv', '/nh'], env).catch(() => null)
|
|
41
|
+
const out = String(r?.stdout ?? '')
|
|
42
|
+
if (/"S-1-5-4"/.test(out)) return 'interactive'
|
|
43
|
+
if (/"S-1-5-2"/.test(out)) return 'network'
|
|
44
|
+
if (/"S-1-5-3"/.test(out)) return 'batch'
|
|
45
|
+
if (/"S-1-5-6"/.test(out)) return 'service'
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A WSL distro's name for `wsl.exe -d`, refused when it could be read as anything else */
|
|
50
|
+
export function wslDistro(distro) {
|
|
51
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(distro ?? '')) throw new Error(`not a WSL distro name: ${distro}`)
|
|
52
|
+
return distro
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A Linux user's name for `wsl.exe -u`, refused when it could be read as anything else */
|
|
56
|
+
export function wslUser(user) {
|
|
57
|
+
if (!/^[A-Za-z_][A-Za-z0-9_.-]{0,31}$/.test(user ?? '')) throw new Error(`cannot name the user ${user} to wsl.exe`)
|
|
58
|
+
return user
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One argument for a Windows command line, as `CommandLineToArgvW` and the C runtime read it: always
|
|
63
|
+
* quoted, backslashes before the closing quote doubled. A double quote or a line break cannot be a
|
|
64
|
+
* path on Windows and is refused rather than escaped.
|
|
65
|
+
*/
|
|
66
|
+
export function windowsArg(arg) {
|
|
67
|
+
const s = String(arg)
|
|
68
|
+
if (/["\r\n\0]/.test(s)) throw new Error(`cannot pass ${JSON.stringify(s)} on a Windows command line`)
|
|
69
|
+
return `"${s.replace(/(\\+)$/, '$1$1')}"`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The first line of a script run by Windows PowerShell 5.1: its own module path. Started from
|
|
74
|
+
* PowerShell 7 (pwsh, a `DefaultShell` of it, a CI runner), 5.1 inherits pwsh's `PSModulePath`, whose
|
|
75
|
+
* first folder holds 7.x copies of the built-in modules that 5.1 cannot load: `Invoke-CimMethod` and
|
|
76
|
+
* `Get-ScheduledTask` are then "found in the module … but the module could not be loaded". Same line as
|
|
77
|
+
* the hub's (links/tunnel.ts, `PS_PREAMBLE`)
|
|
78
|
+
*/
|
|
79
|
+
export const PS_MODULE_PATH = "if ($env:PSModulePath -match '\\x5cPowerShell\\x5c') { $env:PSModulePath = \"$env:ProgramFiles\\WindowsPowerShell\\Modules;$PSHOME\\Modules\" }"
|
|
80
|
+
|
|
81
|
+
/** A PowerShell expression for `s`: base64 decoded there, so no quote reaches PowerShell's parser */
|
|
82
|
+
export function psText(s) {
|
|
83
|
+
return `([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(String(s), 'utf8').toString('base64')}')))`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The PowerShell that asks WMI to create `commandLine`. Values cross as base64, so no quoting can break */
|
|
87
|
+
export function wmiScript({ commandLine, cwd = null }) {
|
|
88
|
+
return [
|
|
89
|
+
"$ErrorActionPreference = 'Stop'",
|
|
90
|
+
PS_MODULE_PATH,
|
|
91
|
+
'[Console]::OutputEncoding = [Text.Encoding]::UTF8',
|
|
92
|
+
`$a = @{ CommandLine = ${psText(commandLine)} }`,
|
|
93
|
+
...(cwd ? [`$a.CurrentDirectory = ${psText(cwd)}`] : []),
|
|
94
|
+
// No console window when this runs from a desktop session; left out where it cannot be built
|
|
95
|
+
'try { $a.ProcessStartupInformation = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 } } catch { }',
|
|
96
|
+
'try {',
|
|
97
|
+
' $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $a',
|
|
98
|
+
" if ($r.ReturnValue -eq 21 -and $a.ContainsKey('ProcessStartupInformation')) { $a.Remove('ProcessStartupInformation'); $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $a }",
|
|
99
|
+
' "CENTRALU-WMI $($r.ReturnValue) $($r.ProcessId)"',
|
|
100
|
+
'} catch { "CENTRALU-WMI-ERROR $($_.Exception.Message)" }',
|
|
101
|
+
].join('\n')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `Win32_Process.Create`'s return values (Microsoft's documentation of the method) */
|
|
105
|
+
const WMI_CODES = { 2: 'access denied', 3: 'insufficient privilege', 8: 'unknown failure', 9: 'path not found', 21: 'invalid parameter' }
|
|
106
|
+
|
|
107
|
+
/** What the WMI script said: `{ ok: true, pid }` or `{ ok: false, message }` */
|
|
108
|
+
export function parseWmiAnswer(stdout) {
|
|
109
|
+
const text = String(stdout ?? '')
|
|
110
|
+
const m = /CENTRALU-WMI (\d+) (\d*)/.exec(text)
|
|
111
|
+
if (m) {
|
|
112
|
+
const rv = Number(m[1])
|
|
113
|
+
if (rv === 0 && Number(m[2]) > 0) return { ok: true, pid: Number(m[2]) }
|
|
114
|
+
return { ok: false, message: `WMI's Win32_Process.Create returned ${rv}${WMI_CODES[rv] ? ` (${WMI_CODES[rv]})` : ''}` }
|
|
115
|
+
}
|
|
116
|
+
const e = /CENTRALU-WMI-ERROR (.*)/.exec(text)
|
|
117
|
+
if (e) return { ok: false, message: `WMI refused to create the process: ${e[1].trim()}` }
|
|
118
|
+
return { ok: false, message: 'PowerShell did not answer the WMI request' }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The sentence for a start WMI did not make. Some managed machines block process creation through
|
|
123
|
+
* WMI (Microsoft Defender's attack surface reduction rule "Block process creations originating from
|
|
124
|
+
* PSExec and WMI commands", off by default); the hub then runs the host bound to its link
|
|
125
|
+
* (docs/plans/remote-hub.md §10.9, decision 3).
|
|
126
|
+
*/
|
|
127
|
+
export function wmiBlockedMessage(detail) {
|
|
128
|
+
return (
|
|
129
|
+
`Windows did not start Centralu through WMI (${detail}). Some managed Windows machines block it ` +
|
|
130
|
+
'(Microsoft Defender\'s attack surface reduction rule "Block process creations originating from PSExec and WMI commands"). ' +
|
|
131
|
+
'Centralu can run there while this computer is linked instead, and stops when the link does.'
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Windows PowerShell 5.1, by full path: under %SystemRoot% on Windows, through the drive WSL mounts inside a distro */
|
|
136
|
+
export function windowsPowershell(how, env, mounts) {
|
|
137
|
+
return windowsTool(how, env, ['System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'], mounts)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A program under the Windows folder, by full path, from Windows or from inside a WSL distro; null when unreachable */
|
|
141
|
+
export function windowsTool(how, env, rel, mounts = () => readFileSync('/proc/mounts', 'utf8')) {
|
|
142
|
+
if (how === 'wmi') return [systemRoot(env), ...rel].join('\\')
|
|
143
|
+
let text = ''
|
|
144
|
+
try {
|
|
145
|
+
text = mounts()
|
|
146
|
+
} catch {
|
|
147
|
+
// no /proc: not WSL after all
|
|
148
|
+
}
|
|
149
|
+
for (const line of text.split('\n')) {
|
|
150
|
+
const [, at, type] = line.split(' ')
|
|
151
|
+
if (!at || !(type === '9p' || type === 'drvfs')) continue
|
|
152
|
+
const candidate = [at, 'Windows', ...rel].join('/')
|
|
153
|
+
if (existsSync(candidate)) return candidate
|
|
154
|
+
}
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A script as `-EncodedCommand` takes it: UTF-16LE, in base64 */
|
|
159
|
+
export function encodedCommand(script) {
|
|
160
|
+
return Buffer.from(script, 'utf16le').toString('base64')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Runs `script` in the Windows PowerShell at `ps` and reads what it printed with `parse` (`{ ok, … }`). When it printed
|
|
165
|
+
* nothing and PowerShell itself complained (a policy, a module it could not load), the answer is the last line of that.
|
|
166
|
+
*/
|
|
167
|
+
export async function askPowershell(ps, script, env, parse, run = runCaptured) {
|
|
168
|
+
const r = await run(ps, ['-NoProfile', '-NonInteractive', '-EncodedCommand', encodedCommand(script)], env)
|
|
169
|
+
const a = parse(r.stdout)
|
|
170
|
+
return a.ok || r.stdout.trim() || !r.stderr.trim() ? a : { ok: false, message: r.stderr.trim().split(/\r?\n/).at(-1) }
|
|
171
|
+
}
|
package/bin/serve.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
* This file is the launcher's `serve` as one module, for `centralu.mjs`, the tests, and the hub's
|
|
19
|
+
* installer (which imports an installed version's `serve.mjs` for `managedLauncherScript`). The code
|
|
20
|
+
* lives beside it, one concern per file: the arguments (`serve-args.mjs`), the files in the data
|
|
21
|
+
* folder (`serve-files.mjs`), the port (`serve-port.mjs`), the launchers and the managed install
|
|
22
|
+
* (`serve-install.mjs`), the host and its socket (`serve-host.mjs`), the foreground serve
|
|
23
|
+
* (`serve-run.mjs`), `--detach` and `--stop` (`serve-detach.mjs`), `--autostart`
|
|
24
|
+
* (`serve-autostart.mjs`), Windows, WSL and PowerShell (`serve-windows.mjs`), and small helpers
|
|
25
|
+
* (`serve-util.mjs`).
|
|
26
|
+
*/
|
|
27
|
+
export { SERVE_STATE_FILE, SERVE_LOG_FILE, SERVE_PID_FILE, serveDataDir, readServeState, ensureServeState, recordServePort, rotateServeToken, heldHere, lockHolderIsAlive, processStartTime, lockHolder, lockHolderPid, readServePid, SERVE_STOPPED_FILE, SERVE_LOGS_KEPT, markStopped, readStopped, rotateServeLog } from './serve-files.mjs'
|
|
28
|
+
export { detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, windowsPowershell, ownLogon } from './serve-windows.mjs'
|
|
29
|
+
export { DEFAULT_SERVE_PORT, SERVE_PORT_TRIES, chooseServePort, listensHere, parseNetstatListening } from './serve-port.mjs'
|
|
30
|
+
export { SERVE_HELP, AUTOSTART_ACTIONS, parseServeArgs } from './serve-args.mjs'
|
|
31
|
+
export { launcherScript, ensureLauncher, remoteLayout, parsePointer, installInfo, managedLauncherScript } from './serve-install.mjs'
|
|
32
|
+
export { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, mismatchServerVersion, hostProvesOf, probeHost, askHostToStop, askHostRpc, serveFrom, takesTokenOnStdin, tokenLine } from './serve-host.mjs'
|
|
33
|
+
export { stopServedHost, moveIntoSignIn, HANDOVER_WAIT_MS } from './serve-handover.mjs'
|
|
34
|
+
export { runWindow } from './serve-window.mjs'
|
|
35
|
+
export { AUTOSTART_FILE, AUTOSTART_ENV, autostartHow, autostartUnsupported, pathKey, autostartName, autostartLauncher, systemdUnit, taskActionScript, taskScript, parseTaskAnswer, readAutostartMark, autostartInfo, lingerMessage, runAutostart, startInSignIn } from './serve-autostart.mjs'
|
|
36
|
+
export { printConnection, runServe, rotateToken } from './serve-run.mjs'
|
|
37
|
+
export { CARRIED_ENV, encodeChildSpec, decodeChildSpec, startDetached, runDetach, runDetachedChild, runStop } from './serve-detach.mjs'
|
package/bin/swap.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replacing an installed copy of the app without a moment where there is none.
|
|
3
|
+
*
|
|
4
|
+
* Lives apart from `centralu.mjs` for the same reason `platform.mjs` does: the launcher runs a
|
|
5
|
+
* command at import time, so a test cannot import it. The paths come in as arguments, so
|
|
6
|
+
* `tooling/launcher-swap.test.ts` runs this against temporary folders instead of `/Applications`.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, renameSync, rmSync } from 'node:fs'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Puts a new copy at `target`: `fill(staging)` writes it beside the old one, the old one is
|
|
12
|
+
* renamed aside, the new one is renamed in, and only then is the old one deleted.
|
|
13
|
+
*
|
|
14
|
+
* The old copy is never deleted before the new one is complete. Deleting first and copying
|
|
15
|
+
* after left a person whose copy failed (a full disk, a permission, an interrupted `ditto`)
|
|
16
|
+
* with no app at all, from a command they ran to update it. A copy that fails here leaves the
|
|
17
|
+
* old install untouched, and a rename that fails puts the old one back.
|
|
18
|
+
*
|
|
19
|
+
* Returns whether something was replaced, and where the old copy was left if it could not be
|
|
20
|
+
* deleted (null when it was).
|
|
21
|
+
*/
|
|
22
|
+
export function swapInto(target, fill, now = Date.now()) {
|
|
23
|
+
const staging = `${target}.new`
|
|
24
|
+
const aside = `${target}.old-${now}`
|
|
25
|
+
rmSync(staging, { recursive: true, force: true })
|
|
26
|
+
try {
|
|
27
|
+
fill(staging)
|
|
28
|
+
} catch (e) {
|
|
29
|
+
rmSync(staging, { recursive: true, force: true })
|
|
30
|
+
throw e
|
|
31
|
+
}
|
|
32
|
+
const replaced = existsSync(target)
|
|
33
|
+
if (replaced) renameSync(target, aside)
|
|
34
|
+
try {
|
|
35
|
+
renameSync(staging, target)
|
|
36
|
+
} catch (e) {
|
|
37
|
+
if (replaced) renameSync(aside, target)
|
|
38
|
+
rmSync(staging, { recursive: true, force: true })
|
|
39
|
+
throw e
|
|
40
|
+
}
|
|
41
|
+
if (!replaced) return { replaced, leftAt: null }
|
|
42
|
+
try {
|
|
43
|
+
rmSync(aside, { recursive: true, force: true })
|
|
44
|
+
return { replaced, leftAt: null }
|
|
45
|
+
} catch {
|
|
46
|
+
return { replaced, leftAt: aside }
|
|
47
|
+
}
|
|
48
|
+
}
|
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.41",
|
|
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",
|
|
@@ -18,8 +18,10 @@
|
|
|
18
18
|
"agent",
|
|
19
19
|
"macos",
|
|
20
20
|
"linux",
|
|
21
|
+
"windows",
|
|
21
22
|
"desktop"
|
|
22
23
|
],
|
|
24
|
+
"protocolVersion": 1,
|
|
23
25
|
"type": "module",
|
|
24
26
|
"bin": {
|
|
25
27
|
"centralu": "bin/centralu.mjs"
|
|
@@ -28,15 +30,17 @@
|
|
|
28
30
|
"bin"
|
|
29
31
|
],
|
|
30
32
|
"engines": {
|
|
31
|
-
"node": ">=22"
|
|
33
|
+
"node": ">=22.14.0"
|
|
32
34
|
},
|
|
33
35
|
"os": [
|
|
34
36
|
"darwin",
|
|
35
|
-
"linux"
|
|
37
|
+
"linux",
|
|
38
|
+
"win32"
|
|
36
39
|
],
|
|
37
40
|
"optionalDependencies": {
|
|
38
|
-
"centralu
|
|
39
|
-
"centralu
|
|
40
|
-
"centralu
|
|
41
|
+
"@centralu/darwin-arm64": "0.1.0-beta.41",
|
|
42
|
+
"@centralu/linux-arm64": "0.1.0-beta.41",
|
|
43
|
+
"@centralu/linux-x64": "0.1.0-beta.41",
|
|
44
|
+
"@centralu/win32-x64": "0.1.0-beta.41"
|
|
41
45
|
}
|
|
42
46
|
}
|