centralu 0.1.0-beta.25 → 0.1.0-beta.26
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/serve.mjs +201 -60
- package/package.json +5 -5
package/bin/serve.mjs
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* import time and cannot be imported by a test. package.json "files" lists the whole bin/
|
|
16
16
|
* directory, so this ships automatically.
|
|
17
17
|
*/
|
|
18
|
-
import { spawn } from 'node:child_process'
|
|
19
|
-
import { createHash, randomBytes } from 'node:crypto'
|
|
18
|
+
import { execFileSync, spawn } from 'node:child_process'
|
|
19
|
+
import { createHash, createHmac, randomBytes } from 'node:crypto'
|
|
20
20
|
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync, closeSync } from 'node:fs'
|
|
21
21
|
import { connect, createServer } from 'node:net'
|
|
22
22
|
import { userInfo } from 'node:os'
|
|
@@ -295,7 +295,29 @@ export function rotateServeToken(dataDir, platform = process.platform) {
|
|
|
295
295
|
*/
|
|
296
296
|
export function launcherScript(platform, execPath, cliPath) {
|
|
297
297
|
if (platform === 'win32') {
|
|
298
|
-
|
|
298
|
+
// A batch file reads every % as the start of a variable (`100% cli.mjs" %*` came out as `100*`); %% is a literal one
|
|
299
|
+
const lit = (v) => String(v).replaceAll('%', '%%')
|
|
300
|
+
/*
|
|
301
|
+
* The file is UTF-8, and cmd.exe reads each line of a batch file in the console's code page as it reaches it: 949
|
|
302
|
+
* on a Korean Windows, where a profile folder named in Korean came out as other characters and node found no
|
|
303
|
+
* centralu.mjs there (#849). So the lines before the paths, all ASCII, switch the console to UTF-8 (65001), and the
|
|
304
|
+
* code page it had is put back on the line with the paths, ahead of node: cmd.exe reads and decodes a whole line
|
|
305
|
+
* before it runs any of it, so the paths are already read as UTF-8 by then. The console is the shell's that ran
|
|
306
|
+
* this, and node runs on the page it had; a Ctrl+C answered at "Terminate batch job (Y/N)?" ends this file
|
|
307
|
+
* before any later line, and a restore there was skipped. chcp is named by its full path: the shell's PATH may
|
|
308
|
+
* not have System32 on it.
|
|
309
|
+
*/
|
|
310
|
+
return [
|
|
311
|
+
'@echo off',
|
|
312
|
+
"rem Written by `centralu serve`: reaches this install without npm's folder on PATH.",
|
|
313
|
+
'setlocal',
|
|
314
|
+
'set "CENTRALU_CP="',
|
|
315
|
+
`for /f "tokens=2 delims=:." %%c in ('%SystemRoot%\\System32\\chcp.com') do set /a "CENTRALU_CP=%%c" >nul 2>&1`,
|
|
316
|
+
'%SystemRoot%\\System32\\chcp.com 65001 >nul 2>&1',
|
|
317
|
+
`(if defined CENTRALU_CP %SystemRoot%\\System32\\chcp.com %CENTRALU_CP% >nul 2>&1) & "${lit(execPath)}" "${lit(cliPath)}" %*`,
|
|
318
|
+
'exit /b %ERRORLEVEL%',
|
|
319
|
+
'',
|
|
320
|
+
].join('\r\n')
|
|
299
321
|
}
|
|
300
322
|
const q = (v) => `'${String(v).replaceAll("'", "'\\''")}'`
|
|
301
323
|
return (
|
|
@@ -398,7 +420,7 @@ export function protocolVersionOf(entry) {
|
|
|
398
420
|
* `install` (additive, the line stays `v: 1`) says what runs this command (`installInfo`); `detach`
|
|
399
421
|
* is added by `--detach` only.
|
|
400
422
|
*/
|
|
401
|
-
export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, autostart, detach }) {
|
|
423
|
+
export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, autostart, detach, proof }) {
|
|
402
424
|
return JSON.stringify({
|
|
403
425
|
v: 1,
|
|
404
426
|
port,
|
|
@@ -410,6 +432,8 @@ export function connectionLine({ port, token, version, protocolVersion, dataDir,
|
|
|
410
432
|
...(install ? { install } : {}),
|
|
411
433
|
...(autostart ? { autostart } : {}),
|
|
412
434
|
...(detach ? { detach } : {}),
|
|
435
|
+
// The running host proves it holds the token before the hub sends it (#838); absent: the hub sends hello as before
|
|
436
|
+
...(proof ? { proof: true } : {}),
|
|
413
437
|
})
|
|
414
438
|
}
|
|
415
439
|
|
|
@@ -584,16 +608,73 @@ export function mismatchServerVersion(message) {
|
|
|
584
608
|
return m ? Number(m[1] ?? m[2]) : null
|
|
585
609
|
}
|
|
586
610
|
|
|
611
|
+
/**
|
|
612
|
+
* Whether the installed host answers a `prove` (#838): a bundle says so in `bundle-info.json` (`hostProof`), a host run
|
|
613
|
+
* from source has `transport/host-proof.ts`. The connection line's `proof` is this, not a live answer: a program another
|
|
614
|
+
* account put on the port could otherwise answer the probe so as to make the line say no, and the hub send the token.
|
|
615
|
+
*/
|
|
616
|
+
export function hostProvesOf(entry) {
|
|
617
|
+
try {
|
|
618
|
+
return JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).hostProof === 1
|
|
619
|
+
} catch {
|
|
620
|
+
// not a bundle; try the source below
|
|
621
|
+
}
|
|
622
|
+
return existsSync(join(dirname(entry), 'transport', 'host-proof.ts'))
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** The answer a host holding `token` gives to a `prove` of `nonce` (the host's `hostProof`) */
|
|
626
|
+
function proofOf(token, nonce) {
|
|
627
|
+
return createHmac('sha256', token).update(`centralu host proof v1\0${nonce}`).digest('base64url')
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Opens a socket to the host and says hello with the token, the way `probeHost` and `askHostToStop` do. With `prove`
|
|
632
|
+
* (the installed host can, `hostProvesOf`), the host first proves it holds the token (#838), and the token goes out only
|
|
633
|
+
* then: whatever else listens on the port (another account's program, while the host is down) never sees it.
|
|
634
|
+
* `on.hello(ws)` runs after the hello is sent; `on.unproven()` when the proof did not come.
|
|
635
|
+
*/
|
|
636
|
+
function helloSocket({ port, token, protocolVersion, prove, WebSocketImpl, on }) {
|
|
637
|
+
const ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
|
|
638
|
+
const nonce = prove ? randomBytes(24).toString('base64url') : null
|
|
639
|
+
// Proving only once the socket is open: a port where nothing listens is gone, not unproven
|
|
640
|
+
let proving = false
|
|
641
|
+
const hello = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
|
|
642
|
+
ws.onopen = () => {
|
|
643
|
+
if (!prove) return hello()
|
|
644
|
+
proving = true
|
|
645
|
+
ws.send(JSON.stringify({ kind: 'prove', nonce }))
|
|
646
|
+
}
|
|
647
|
+
ws.onmessage = (ev) => {
|
|
648
|
+
let m
|
|
649
|
+
try {
|
|
650
|
+
m = JSON.parse(String(ev.data))
|
|
651
|
+
} catch {
|
|
652
|
+
m = null
|
|
653
|
+
}
|
|
654
|
+
if (proving) {
|
|
655
|
+
proving = false
|
|
656
|
+
if (m?.kind === 'proof' && m.nonce === nonce && m.mac === proofOf(token, nonce)) return hello()
|
|
657
|
+
return on.unproven()
|
|
658
|
+
}
|
|
659
|
+
if (m) on.message(m)
|
|
660
|
+
}
|
|
661
|
+
ws.onerror = () => (proving ? on.unproven() : on.gone())
|
|
662
|
+
ws.onclose = () => (proving ? on.unproven() : on.gone())
|
|
663
|
+
return ws
|
|
664
|
+
}
|
|
665
|
+
|
|
587
666
|
/**
|
|
588
667
|
* Whether a host that holds this token answers on the port: a real hello, not a TCP connect, so a
|
|
589
668
|
* stranger's program on the same port does not count, and the answer carries the running host's
|
|
590
669
|
* own protocol and version (which differ from the installed package's after an `npm i -g` the
|
|
591
|
-
* host was not restarted for).
|
|
670
|
+
* host was not restarted for). With `prove` the host proves it holds the token first (#838).
|
|
592
671
|
*
|
|
593
|
-
* `{ running, protocolVersion?, version? }`.
|
|
594
|
-
*
|
|
672
|
+
* `{ running, protocolVersion?, version?, unproven? }`. `unproven`: something answered on the port but did not prove
|
|
673
|
+
* it holds the token, so it was not sent: another program, or a host from before #838 still running after an update
|
|
674
|
+
* (`heldHere` tells those apart by the data folder's lock). Node 22.4+ has a global WebSocket; on an older 22 this falls
|
|
675
|
+
* back to a TCP connect and reports only `running`.
|
|
595
676
|
*/
|
|
596
|
-
export function probeHost({ port, token, protocolVersion, timeoutMs = 2000, WebSocketImpl = globalThis.WebSocket }) {
|
|
677
|
+
export function probeHost({ port, token, protocolVersion, prove = false, timeoutMs = 2000, WebSocketImpl = globalThis.WebSocket }) {
|
|
597
678
|
if (typeof WebSocketImpl !== 'function') return tcpProbe(port, timeoutMs)
|
|
598
679
|
return new Promise((resolve) => {
|
|
599
680
|
let ws = null
|
|
@@ -611,35 +692,84 @@ export function probeHost({ port, token, protocolVersion, timeoutMs = 2000, WebS
|
|
|
611
692
|
}
|
|
612
693
|
const timer = setTimeout(() => done({ running: false }), timeoutMs)
|
|
613
694
|
try {
|
|
614
|
-
ws =
|
|
695
|
+
ws = helloSocket({
|
|
696
|
+
port, token, protocolVersion, prove, WebSocketImpl,
|
|
697
|
+
on: {
|
|
698
|
+
unproven: () => done({ running: false, unproven: true }),
|
|
699
|
+
gone: () => done({ running: false }),
|
|
700
|
+
message: (m) => {
|
|
701
|
+
if (m.kind === 'hello_ok') {
|
|
702
|
+
done({
|
|
703
|
+
running: true,
|
|
704
|
+
protocolVersion: Number.isInteger(m.protocolVersion) ? m.protocolVersion : undefined,
|
|
705
|
+
version: typeof m.build?.version === 'string' ? m.build.version : undefined,
|
|
706
|
+
})
|
|
707
|
+
} else if (m.kind === 'res' && m.ok === false && m.error?.code === 'version_mismatch') {
|
|
708
|
+
// The token was right (it is checked first), so this is our host, on another protocol
|
|
709
|
+
done({ running: true, protocolVersion: mismatchServerVersion(m.error.message) ?? undefined })
|
|
710
|
+
}
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
})
|
|
615
714
|
} catch {
|
|
616
715
|
done({ running: false })
|
|
617
|
-
return
|
|
618
716
|
}
|
|
619
|
-
ws.onopen = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
|
|
620
|
-
ws.onmessage = (ev) => {
|
|
621
|
-
let m
|
|
622
|
-
try {
|
|
623
|
-
m = JSON.parse(String(ev.data))
|
|
624
|
-
} catch {
|
|
625
|
-
return
|
|
626
|
-
}
|
|
627
|
-
if (m?.kind === 'hello_ok') {
|
|
628
|
-
done({
|
|
629
|
-
running: true,
|
|
630
|
-
protocolVersion: Number.isInteger(m.protocolVersion) ? m.protocolVersion : undefined,
|
|
631
|
-
version: typeof m.build?.version === 'string' ? m.build.version : undefined,
|
|
632
|
-
})
|
|
633
|
-
} else if (m?.kind === 'res' && m.ok === false && m.error?.code === 'version_mismatch') {
|
|
634
|
-
// The token was right (it is checked first), so this is our host, on another protocol
|
|
635
|
-
done({ running: true, protocolVersion: mismatchServerVersion(m.error.message) ?? undefined })
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
ws.onerror = () => done({ running: false })
|
|
639
|
-
ws.onclose = () => done({ running: false })
|
|
640
717
|
})
|
|
641
718
|
}
|
|
642
719
|
|
|
720
|
+
/**
|
|
721
|
+
* A probe that did not prove the token, while this data folder's host is alive (`host.lock`): a host from before #838
|
|
722
|
+
* still running after an update. It is running, and is not sent the token; a hub asking it for a proof is refused
|
|
723
|
+
* until it restarts. Without a live lock holder, what answered is not ours.
|
|
724
|
+
*/
|
|
725
|
+
export function heldHere(probe, dataDir, startOf = processStartTime) {
|
|
726
|
+
if (probe.running) return true
|
|
727
|
+
if (!probe.unproven) return false
|
|
728
|
+
return lockHolderIsAlive(dataDir, startOf)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Whether the process `host.lock` names is the one that wrote it (#838 review): alive, and started when the lock says
|
|
733
|
+
* (`ps -o lstart`, as the host writes it, `instance-lock.ts`). A lock left by a host that died, whose pid another
|
|
734
|
+
* process of the same user now has, names a stranger: counted as running, `--stop` would signal it. A lock without a
|
|
735
|
+
* start time (a host from before #184) or a system without `ps` (Windows) cannot be told apart, so it does not count.
|
|
736
|
+
*/
|
|
737
|
+
export function lockHolderIsAlive(dataDir, startOf = processStartTime) {
|
|
738
|
+
let holder
|
|
739
|
+
try {
|
|
740
|
+
holder = parseLockHolder(readFileSync(join(dataDir, 'host.lock'), 'utf8'))
|
|
741
|
+
} catch {
|
|
742
|
+
return false
|
|
743
|
+
}
|
|
744
|
+
if (!holder || !holder.started || !isAlive(holder.pid)) return false
|
|
745
|
+
return startOf(holder.pid) === holder.started
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** host.lock's `{ pid, started }`; a bare pid (an older host) has no start time */
|
|
749
|
+
function parseLockHolder(text) {
|
|
750
|
+
const t = text.trim()
|
|
751
|
+
if (/^\d+$/.test(t)) return { pid: Number(t), started: null }
|
|
752
|
+
try {
|
|
753
|
+
const v = JSON.parse(t)
|
|
754
|
+
return Number.isInteger(v?.pid) && v.pid > 0 ? { pid: v.pid, started: typeof v.started === 'string' ? v.started : null } : null
|
|
755
|
+
} catch {
|
|
756
|
+
return null
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* A pid's start time, as `instance-lock.ts`'s `processStartTime` reads it: `ps -o lstart=` with the C locale, so the
|
|
762
|
+
* host that wrote the lock and this launcher read the same text. null without `ps` (Windows) or when it fails.
|
|
763
|
+
*/
|
|
764
|
+
export function processStartTime(pid) {
|
|
765
|
+
if (process.platform === 'win32') return null
|
|
766
|
+
try {
|
|
767
|
+
return execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000, env: { ...process.env, LC_ALL: 'C' }, stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null
|
|
768
|
+
} catch {
|
|
769
|
+
return null
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
643
773
|
function tcpProbe(port, timeoutMs) {
|
|
644
774
|
return new Promise((resolve) => {
|
|
645
775
|
const s = connect({ host: '127.0.0.1', port })
|
|
@@ -688,16 +818,19 @@ function say(line) {
|
|
|
688
818
|
async function describe({ env, dataDir, state, entry, version }) {
|
|
689
819
|
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
690
820
|
const installed = protocolVersionOf(entry)
|
|
691
|
-
|
|
821
|
+
// Whether the installed host proves it holds the token (#838): asked of the running one before its hello, and said in the line
|
|
822
|
+
const proof = hostProvesOf(entry)
|
|
823
|
+
const probe = await probeHost({ port, token: state.token, protocolVersion: installed, prove: proof })
|
|
692
824
|
const protocolVersion = (probe.running ? probe.protocolVersion : undefined) ?? installed
|
|
693
825
|
if (protocolVersion === null) return null
|
|
694
826
|
return {
|
|
827
|
+
proof,
|
|
695
828
|
port,
|
|
696
829
|
token: state.token,
|
|
697
830
|
version: (probe.running ? probe.version : undefined) ?? version,
|
|
698
831
|
protocolVersion,
|
|
699
832
|
dataDir,
|
|
700
|
-
hostRunning: probe
|
|
833
|
+
hostRunning: heldHere(probe, dataDir),
|
|
701
834
|
install: installInfo(dataDir, env),
|
|
702
835
|
autostart: autostartInfo(dataDir, process.platform, env),
|
|
703
836
|
}
|
|
@@ -760,8 +893,8 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
760
893
|
// A serve that is already up answers on the port it recorded; say so before starting a host that
|
|
761
894
|
// would only fail on the data folder's lock with a less specific message.
|
|
762
895
|
const recorded = state.port ?? DEFAULT_SERVE_PORT
|
|
763
|
-
const existing = await probeHost({ port: recorded, token: state.token, protocolVersion })
|
|
764
|
-
if (existing
|
|
896
|
+
const existing = await probeHost({ port: recorded, token: state.token, protocolVersion, prove: hostProvesOf(entry) })
|
|
897
|
+
if (heldHere(existing, dataDir)) {
|
|
765
898
|
const pid = lockHolderPid(dataDir)
|
|
766
899
|
say(`centralu serve is already running for ${dataDir} on 127.0.0.1:${recorded}${pid ? ` (pid ${pid})` : ''}. Not starting a second one.`)
|
|
767
900
|
return 1
|
|
@@ -934,11 +1067,21 @@ export function windowsArg(arg) {
|
|
|
934
1067
|
return `"${s.replace(/(\\+)$/, '$1$1')}"`
|
|
935
1068
|
}
|
|
936
1069
|
|
|
1070
|
+
/**
|
|
1071
|
+
* The first line of a script run by Windows PowerShell 5.1: its own module path. Started from
|
|
1072
|
+
* PowerShell 7 (pwsh, a `DefaultShell` of it, a CI runner), 5.1 inherits pwsh's `PSModulePath`, whose
|
|
1073
|
+
* first folder holds 7.x copies of the built-in modules that 5.1 cannot load: `Invoke-CimMethod` and
|
|
1074
|
+
* `Get-ScheduledTask` are then "found in the module … but the module could not be loaded". Same line as
|
|
1075
|
+
* the hub's (links/tunnel.ts, `PS_PREAMBLE`)
|
|
1076
|
+
*/
|
|
1077
|
+
const PS_MODULE_PATH = "if ($env:PSModulePath -match '\\x5cPowerShell\\x5c') { $env:PSModulePath = \"$env:ProgramFiles\\WindowsPowerShell\\Modules;$PSHOME\\Modules\" }"
|
|
1078
|
+
|
|
937
1079
|
/** The PowerShell that asks WMI to create `commandLine`. Values cross as base64, so no quoting can break */
|
|
938
1080
|
export function wmiScript({ commandLine, cwd = null }) {
|
|
939
1081
|
const text = (s) => `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(s, 'utf8').toString('base64')}'))`
|
|
940
1082
|
return [
|
|
941
1083
|
"$ErrorActionPreference = 'Stop'",
|
|
1084
|
+
PS_MODULE_PATH,
|
|
942
1085
|
'[Console]::OutputEncoding = [Text.Encoding]::UTF8',
|
|
943
1086
|
`$a = @{ CommandLine = ${text(commandLine)} }`,
|
|
944
1087
|
...(cwd ? [`$a.CurrentDirectory = ${text(cwd)}`] : []),
|
|
@@ -1205,7 +1348,7 @@ export async function runDetachedChild({ spec, ...opts }) {
|
|
|
1205
1348
|
* `{ ok: true }` once the host took it, `{ ok: false, message, protocolVersion? }` otherwise; the
|
|
1206
1349
|
* last names the host's protocol when the hello was refused for it, so the caller can ask again.
|
|
1207
1350
|
*/
|
|
1208
|
-
export function askHostToStop({ port, token, protocolVersion, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
|
|
1351
|
+
export function askHostToStop({ port, token, protocolVersion, prove = false, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
|
|
1209
1352
|
if (typeof WebSocketImpl !== 'function') return Promise.resolve({ ok: false, message: `Node ${process.version} has no WebSocket to ask with` })
|
|
1210
1353
|
return new Promise((resolve) => {
|
|
1211
1354
|
let ws = null
|
|
@@ -1223,28 +1366,25 @@ export function askHostToStop({ port, token, protocolVersion, timeoutMs = 5000,
|
|
|
1223
1366
|
}
|
|
1224
1367
|
const timer = setTimeout(() => done({ ok: false, message: 'no answer in time' }), timeoutMs)
|
|
1225
1368
|
try {
|
|
1226
|
-
|
|
1369
|
+
// With `prove`, the token goes out only to a host that proved it holds it (#838)
|
|
1370
|
+
ws = helloSocket({
|
|
1371
|
+
port, token, protocolVersion, prove, WebSocketImpl,
|
|
1372
|
+
on: {
|
|
1373
|
+
unproven: () => done({ ok: false, message: 'what answers on the port did not prove it holds the token' }),
|
|
1374
|
+
gone: () => done({ ok: false, message: 'the connection closed' }),
|
|
1375
|
+
message: (m) => {
|
|
1376
|
+
if (m.kind === 'hello_ok') ws.send(JSON.stringify({ kind: 'rpc', id: 'stop', method: 'host.stop', params: {} }))
|
|
1377
|
+
else if (m.kind === 'res' && m.id === 'stop') done(m.ok ? { ok: true } : { ok: false, message: m.error?.message ?? 'refused' })
|
|
1378
|
+
else if (m.kind === 'res' && m.ok === false) {
|
|
1379
|
+
const pv = m.error?.code === 'version_mismatch' ? (m.error?.data?.protocolVersion ?? mismatchServerVersion(m.error.message)) : null
|
|
1380
|
+
done({ ok: false, message: m.error?.message ?? 'refused', ...(Number.isInteger(pv) ? { protocolVersion: pv } : {}) })
|
|
1381
|
+
}
|
|
1382
|
+
},
|
|
1383
|
+
},
|
|
1384
|
+
})
|
|
1227
1385
|
} catch (e) {
|
|
1228
1386
|
done({ ok: false, message: e.message })
|
|
1229
|
-
return
|
|
1230
|
-
}
|
|
1231
|
-
ws.onopen = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
|
|
1232
|
-
ws.onmessage = (ev) => {
|
|
1233
|
-
let m
|
|
1234
|
-
try {
|
|
1235
|
-
m = JSON.parse(String(ev.data))
|
|
1236
|
-
} catch {
|
|
1237
|
-
return
|
|
1238
|
-
}
|
|
1239
|
-
if (m?.kind === 'hello_ok') ws.send(JSON.stringify({ kind: 'rpc', id: 'stop', method: 'host.stop', params: {} }))
|
|
1240
|
-
else if (m?.kind === 'res' && m.id === 'stop') done(m.ok ? { ok: true } : { ok: false, message: m.error?.message ?? 'refused' })
|
|
1241
|
-
else if (m?.kind === 'res' && m.ok === false) {
|
|
1242
|
-
const pv = m.error?.code === 'version_mismatch' ? (m.error?.data?.protocolVersion ?? mismatchServerVersion(m.error.message)) : null
|
|
1243
|
-
done({ ok: false, message: m.error?.message ?? 'refused', ...(Number.isInteger(pv) ? { protocolVersion: pv } : {}) })
|
|
1244
|
-
}
|
|
1245
1387
|
}
|
|
1246
|
-
ws.onerror = () => done({ ok: false, message: 'could not connect' })
|
|
1247
|
-
ws.onclose = () => done({ ok: false, message: 'the connection closed' })
|
|
1248
1388
|
})
|
|
1249
1389
|
}
|
|
1250
1390
|
|
|
@@ -1293,9 +1433,10 @@ export async function runStop({
|
|
|
1293
1433
|
if (!state) return report({ ok: true, wasRunning: false, message: `no centralu serve has run for ${dataDir}` })
|
|
1294
1434
|
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
1295
1435
|
const installed = protocolVersionOf(entry) ?? 0
|
|
1296
|
-
const
|
|
1436
|
+
const prove = hostProvesOf(entry)
|
|
1437
|
+
const probe = () => probeHost({ port, token: state.token, protocolVersion: installed, prove })
|
|
1297
1438
|
const first = await probe()
|
|
1298
|
-
if (!first
|
|
1439
|
+
if (!heldHere(first, dataDir)) return report({ ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}` })
|
|
1299
1440
|
|
|
1300
1441
|
const hostPid = lockHolderPid(dataDir)
|
|
1301
1442
|
const launcher = readServePid(dataDir)
|
|
@@ -1303,8 +1444,8 @@ export async function runStop({
|
|
|
1303
1444
|
const launcherPid = launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
|
|
1304
1445
|
const down = async () => !(await probe()).running && !(hostPid && isAlive(hostPid))
|
|
1305
1446
|
|
|
1306
|
-
let asked = await askHostToStop({ port, token: state.token, protocolVersion: first.protocolVersion ?? installed })
|
|
1307
|
-
if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, token: state.token, protocolVersion: asked.protocolVersion })
|
|
1447
|
+
let asked = await askHostToStop({ port, token: state.token, protocolVersion: first.protocolVersion ?? installed, prove })
|
|
1448
|
+
if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, token: state.token, protocolVersion: asked.protocolVersion, prove })
|
|
1308
1449
|
let how = 'asked'
|
|
1309
1450
|
let stopped = asked.ok && (await waitUntil(down, timeoutMs))
|
|
1310
1451
|
let note = asked.ok ? null : `the host did not take the request to stop (${asked.message})`
|
|
@@ -1499,7 +1640,7 @@ const TASK_WORD = 'CENTRALU-AUTOSTART'
|
|
|
1499
1640
|
* on battery too, and with no time limit (the default ends a task after 72 hours).
|
|
1500
1641
|
*/
|
|
1501
1642
|
export function taskScript(action, { name, run = '' }) {
|
|
1502
|
-
const head = ["$ErrorActionPreference = 'Stop'", '[Console]::OutputEncoding = [Text.Encoding]::UTF8', `$name = ${psText(name)}`]
|
|
1643
|
+
const head = ["$ErrorActionPreference = 'Stop'", PS_MODULE_PATH, '[Console]::OutputEncoding = [Text.Encoding]::UTF8', `$name = ${psText(name)}`]
|
|
1503
1644
|
const fail = `} catch { "${TASK_WORD}-ERROR $($_.Exception.Message -replace '\\s+', ' ')" }`
|
|
1504
1645
|
const find = "$t = Get-ScheduledTask -TaskName $name -TaskPath '\\' -ErrorAction SilentlyContinue"
|
|
1505
1646
|
if (action === 'status') return [...head, 'try {', ` ${find}`, ` if ($t) { "${TASK_WORD} state $($t.State)" } else { '${TASK_WORD} state none' }`, fail].join('\n')
|
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.26",
|
|
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.26",
|
|
42
|
+
"@centralu/linux-arm64": "0.1.0-beta.26",
|
|
43
|
+
"@centralu/linux-x64": "0.1.0-beta.26",
|
|
44
|
+
"@centralu/win32-x64": "0.1.0-beta.26"
|
|
45
45
|
}
|
|
46
46
|
}
|