centralu 0.1.0-beta.30 → 0.1.0-beta.31
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-autostart.mjs +30 -10
- package/bin/serve-detach.mjs +66 -23
- package/bin/serve-files.mjs +69 -2
- package/bin/serve-host.mjs +3 -1
- package/bin/serve-run.mjs +6 -2
- package/bin/serve.mjs +1 -1
- package/package.json +5 -5
package/bin/serve-autostart.mjs
CHANGED
|
@@ -194,10 +194,18 @@ export function taskScript(action, { name, run = '', legacy = null }) {
|
|
|
194
194
|
const head = ["$ErrorActionPreference = 'Stop'", PS_MODULE_PATH, '[Console]::OutputEncoding = [Text.Encoding]::UTF8', `$name = ${psText(name)}`]
|
|
195
195
|
const fail = `} catch { "${TASK_WORD}-ERROR $($_.Exception.Message -replace '\\s+', ' ')" }`
|
|
196
196
|
const find = "$t = Get-ScheduledTask -TaskName $name -TaskPath '\\' -ErrorAction SilentlyContinue"
|
|
197
|
-
|
|
197
|
+
/*
|
|
198
|
+
* Who this user is: their account's SID, and its name as Windows resolves it (`PC\user`, `DOMAIN\user`), which a task
|
|
199
|
+
* principal is registered with. Never USERDOMAIN: in an ssh logon on a PC in a workgroup it says `WORKGROUP`, which
|
|
200
|
+
* names no account (#1223, measured on a laptop with beta.30). Whether a task's principal is this user's, as this
|
|
201
|
+
* serve registers it, is decided by SID too: Task Scheduler gives the id with the domain, without it, or as a SID
|
|
202
|
+
*/
|
|
198
203
|
const mine = [
|
|
199
|
-
'$
|
|
200
|
-
|
|
204
|
+
'$account = [System.Security.Principal.WindowsIdentity]::GetCurrent()',
|
|
205
|
+
'$sid = $account.User.Value',
|
|
206
|
+
'$me = $account.Name',
|
|
207
|
+
"function Get-Sid($n) { if (\"$n\" -match '^S-1-') { return \"$n\" }; if (-not \"$n\") { return $null }; try { (New-Object System.Security.Principal.NTAccount(\"$n\")).Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { $null } }",
|
|
208
|
+
"function Test-Mine($x) { $x -and $x.Principal -and ((Get-Sid $x.Principal.UserId) -eq $sid) -and \"$($x.Principal.LogonType)\" -eq 'Interactive' -and \"$($x.Principal.RunLevel)\" -eq 'Limited' }",
|
|
201
209
|
]
|
|
202
210
|
const old = legacy
|
|
203
211
|
? [`$legacy = ${psText(legacy)}`, "$old = Get-ScheduledTask -TaskName $legacy -TaskPath '\\' -ErrorAction SilentlyContinue"]
|
|
@@ -238,15 +246,21 @@ export function taskScript(action, { name, run = '', legacy = null }) {
|
|
|
238
246
|
if (action === 'signin') {
|
|
239
247
|
/*
|
|
240
248
|
* Someone is signed in when this user owns an Explorer in a session of its own (not session 0, where an ssh
|
|
241
|
-
* logon's processes run). Asked of WMI for the owner, which needs no administrator for the user's own
|
|
242
|
-
*
|
|
249
|
+
* logon's processes run). Asked of WMI for the owner's SID, which needs no administrator for the user's own
|
|
250
|
+
* processes, reads the same in every language, and does not depend on what this logon calls its domain (GetOwner's
|
|
251
|
+
* domain is the PC's name for a local account, while an ssh logon's USERDOMAIN may say `WORKGROUP`). When none is
|
|
252
|
+
* found, how many desktop Explorers there were at all, and how many of their owners WMI would not tell, goes into
|
|
253
|
+
* serve.log's line
|
|
243
254
|
*/
|
|
244
255
|
return [
|
|
245
256
|
...head,
|
|
246
257
|
'try {',
|
|
247
258
|
...mine.map((l) => ` ${l}`),
|
|
248
|
-
" $
|
|
249
|
-
|
|
259
|
+
" $all = @(Get-CimInstance Win32_Process -Filter \"Name='explorer.exe'\" | Where-Object { $_.SessionId -gt 0 })",
|
|
260
|
+
' $owners = @($all | ForEach-Object { Invoke-CimMethod -InputObject $_ -MethodName GetOwnerSid })',
|
|
261
|
+
' $desk = @($owners | Where-Object { $_.ReturnValue -eq 0 -and $_.Sid -eq $sid })',
|
|
262
|
+
' $unread = @($owners | Where-Object { $_.ReturnValue -ne 0 }).Count',
|
|
263
|
+
` if ($desk.Count -eq 0) { "${TASK_WORD} nosession desktops=$($all.Count) unreadable=$unread" } else {`,
|
|
250
264
|
...[...want, ...old, find, dropOld].map((l) => ` ${l}`),
|
|
251
265
|
' $acts = @(if ($t) { $t.Actions })',
|
|
252
266
|
' $same = (Test-Mine $t) -and $acts.Count -eq 1 -and $acts[0].Execute -eq $exe -and $acts[0].Arguments -eq $arg',
|
|
@@ -409,7 +423,9 @@ async function taskAutostart(action, { dataDir, env, home, name, run, user, plat
|
|
|
409
423
|
* network logon, which cannot open the user's Credential Manager vault, runs elevated for an administrator (#702) and
|
|
410
424
|
* opens what it shows in session 0, where nobody sees it.
|
|
411
425
|
*
|
|
412
|
-
* `{ started: true, name }`, or `{ started: false, reason, message? }`: `nosession` (
|
|
426
|
+
* `{ started: true, name }`, or `{ started: false, reason, message?, desktops? }`: `nosession` (this user is not signed
|
|
427
|
+
* in here; `desktops` is how many desktop Explorers, of any account, there were, `unreadable` how many of their owners
|
|
428
|
+
* WMI would not tell),
|
|
413
429
|
* `off` (`CENTRALU_SIGNIN_TASK=off`, for a person who wants no task, and for tests on a machine with a desktop),
|
|
414
430
|
* `not_windows`, `no_launcher`, `error`. `run` is a parameter so tests never reach the real Task Scheduler.
|
|
415
431
|
*/
|
|
@@ -422,10 +438,14 @@ export async function startInSignIn({ dataDir, env, home, platform = process.pla
|
|
|
422
438
|
if (!ps) return { started: false, reason: 'error', message: 'Windows PowerShell is not reachable' }
|
|
423
439
|
const name = autostartName('task', { dataDir, home, env, platform, user })
|
|
424
440
|
const runScript = taskActionScript({ how: 'wmi', launcher, env })
|
|
425
|
-
const
|
|
441
|
+
const parse = (out) => {
|
|
442
|
+
const m = /nosession desktops=(\d+)(?: unreadable=(\d+))?/.exec(String(out ?? ''))
|
|
443
|
+
return { ...parseTaskAnswer(out), ...(m ? { desktops: Number(m[1]), unreadable: Number(m[2] ?? 0) } : {}) }
|
|
444
|
+
}
|
|
445
|
+
const a = await askPowershell(ps, taskScript('signin', { name, run: runScript, legacy: legacyTaskName({ dataDir, home, env, platform }) }), env, parse, run)
|
|
426
446
|
if (!a.ok) return { started: false, reason: 'error', message: a.message }
|
|
427
447
|
if (a.word === 'started') return { started: true, name }
|
|
428
|
-
if (a.word === 'nosession') return { started: false, reason: 'nosession' }
|
|
448
|
+
if (a.word === 'nosession') return { started: false, reason: 'nosession', ...(a.desktops !== undefined ? { desktops: a.desktops, unreadable: a.unreadable } : {}) }
|
|
429
449
|
return { started: false, reason: 'error', message: `Task Scheduler answered ${a.word}` }
|
|
430
450
|
}
|
|
431
451
|
|
package/bin/serve-detach.mjs
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* stopping it in order (docs/plans/remote-hub.md §10.4).
|
|
4
4
|
*/
|
|
5
5
|
import { spawn } from 'node:child_process'
|
|
6
|
-
import { existsSync, openSync,
|
|
6
|
+
import { existsSync, openSync, rmSync, writeFileSync, writeSync } from 'node:fs'
|
|
7
7
|
import { userInfo } from 'node:os'
|
|
8
8
|
import { join } from 'node:path'
|
|
9
9
|
import { answerLine, isAlive, say, sayTo, runCaptured, tail, sleep } from './serve-util.mjs'
|
|
10
|
-
import { SERVE_LOG_FILE, serveDataDir, ensureServeState } from './serve-files.mjs'
|
|
10
|
+
import { SERVE_LOG_FILE, serveDataDir, ensureServeState, markStopped, rotateServeLog } from './serve-files.mjs'
|
|
11
11
|
import { askPowershell, detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, windowsPowershell, windowsTool, wslDistro, wslUser } from './serve-windows.mjs'
|
|
12
12
|
import { CHILD_FLAG } from './serve-args.mjs'
|
|
13
13
|
import { startInSignIn } from './serve-autostart.mjs'
|
|
@@ -22,20 +22,24 @@ import { describe, runServe, serveFolder } from './serve-run.mjs'
|
|
|
22
22
|
*/
|
|
23
23
|
export const CARRIED_ENV = ['CC_DATA_DIR', 'CENTRALU_HOST_ENTRY', 'CENTRALU_MANAGED', 'CENTRALU_STARTED_FROM']
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* `start` is how the launcher was started and why (`through WMI: <reason>`), which it writes into its first serve.log
|
|
27
|
+
* line, so a host that ended up outside the Windows sign-in says why (#1223)
|
|
28
|
+
*/
|
|
29
|
+
export function encodeChildSpec({ env, log, start = null }) {
|
|
26
30
|
const carried = {}
|
|
27
31
|
for (const k of CARRIED_ENV) if (typeof env[k] === 'string' && env[k]) carried[k] = env[k]
|
|
28
|
-
return Buffer.from(JSON.stringify({ log, env: carried }), 'utf8').toString('base64url')
|
|
32
|
+
return Buffer.from(JSON.stringify({ log, env: carried, ...(start ? { start } : {}) }), 'utf8').toString('base64url')
|
|
29
33
|
}
|
|
30
34
|
|
|
31
|
-
/** `{ log, env }` from `--detached-child=`, only the carried variables; null when it is not one */
|
|
35
|
+
/** `{ log, env, start? }` from `--detached-child=`, only the carried variables; null when it is not one */
|
|
32
36
|
export function decodeChildSpec(raw) {
|
|
33
37
|
try {
|
|
34
38
|
const v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'))
|
|
35
39
|
if (typeof v?.log !== 'string' || !v.log) return null
|
|
36
40
|
const env = {}
|
|
37
41
|
for (const k of CARRIED_ENV) if (typeof v.env?.[k] === 'string') env[k] = v.env[k]
|
|
38
|
-
return { log: v.log, env }
|
|
42
|
+
return { log: v.log, env, ...(typeof v.start === 'string' && v.start ? { start: v.start } : {}) }
|
|
39
43
|
} catch {
|
|
40
44
|
return null
|
|
41
45
|
}
|
|
@@ -135,40 +139,52 @@ export async function runDetach({
|
|
|
135
139
|
}
|
|
136
140
|
if (await answered(true)) return 0
|
|
137
141
|
|
|
142
|
+
const log = join(dataDir, SERVE_LOG_FILE)
|
|
143
|
+
/*
|
|
144
|
+
* One start per log: the earlier ones are kept beside it, three of them (`rotateServeLog`, #1143), and its absence is
|
|
145
|
+
* how a start that never ran shows. So a log that cannot be moved aside is removed rather than left to read as a start
|
|
146
|
+
*/
|
|
147
|
+
const freshLog = () => rotateServeLog(log)
|
|
148
|
+
|
|
138
149
|
/*
|
|
139
150
|
* Windows: in the person's sign-in when someone is signed in (#1224), through the scheduled task, and through WMI only
|
|
140
151
|
* when nobody is. WMI's process keeps this ssh session's network logon, where Git cannot use the credentials saved in
|
|
141
152
|
* Windows. A task-started host that does not answer in time is left to the lock: the WMI start below then either
|
|
142
|
-
* holds the folder or is refused by it, never a second host on it.
|
|
153
|
+
* holds the folder or is refused by it, never a second host on it. Which way it went, and why, is one line of
|
|
154
|
+
* serve.log (#1223): the task's serve writes no serve.log, so that line is written here; a WMI launcher writes its own
|
|
143
155
|
*/
|
|
156
|
+
let notInSignIn = null
|
|
144
157
|
if (how === 'wmi') {
|
|
145
158
|
const s = await signIn({ dataDir, env, home, platform })
|
|
146
159
|
if (s.started) {
|
|
147
|
-
|
|
160
|
+
const line = `started centralu serve in the Windows sign-in (the scheduled task "${s.name}"); waiting for its host`
|
|
161
|
+
say(line)
|
|
162
|
+
freshLog()
|
|
163
|
+
try {
|
|
164
|
+
writeFileSync(log, `[centralu serve] ${line}\n`)
|
|
165
|
+
} catch {
|
|
166
|
+
// the line on stderr is all there is
|
|
167
|
+
}
|
|
148
168
|
const t0 = Date.now()
|
|
149
169
|
while (Date.now() - t0 < signInMs) {
|
|
150
170
|
if (await answered(false, 'task')) return 0
|
|
151
171
|
await sleep(250)
|
|
152
172
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
} else
|
|
157
|
-
|
|
173
|
+
notInSignIn = `the host the sign-in task "${s.name}" started did not answer within ${Math.round(signInMs / 1000)} s`
|
|
174
|
+
// The WMI launcher's first line says so; the task's line goes, so `serve.log.1` keeps the start before this one
|
|
175
|
+
rmSync(log, { force: true, maxRetries: 5 })
|
|
176
|
+
} else {
|
|
177
|
+
notInSignIn = signInMissed(s)
|
|
158
178
|
}
|
|
179
|
+
say(`${notInSignIn}: starting the host through WMI${s.reason === 'nosession' ? ', outside any sign-in, where Git cannot use the credentials saved in Windows' : ''}`)
|
|
159
180
|
}
|
|
160
181
|
|
|
161
|
-
|
|
162
|
-
// One start per log: the last one's is kept beside it, and its absence is how a start that never ran shows
|
|
163
|
-
try {
|
|
164
|
-
renameSync(log, `${log}.1`)
|
|
165
|
-
} catch {
|
|
166
|
-
// there was none
|
|
167
|
-
}
|
|
182
|
+
freshLog()
|
|
168
183
|
// Where this was asked for from (#1223): the launcher runs outside this ssh session, so it is told
|
|
169
184
|
const asked = serveFrom(env, from)
|
|
170
185
|
const childEnv = asked ? { ...env, CENTRALU_STARTED_FROM: JSON.stringify(asked) } : env
|
|
171
|
-
const
|
|
186
|
+
const startedHow = `through ${how === 'wmi' ? 'WMI' : how === 'wsl' ? 'a WMI-created wsl.exe' : how}${notInSignIn ? `: ${notInSignIn}` : ''}`
|
|
187
|
+
const args = [cliPath, 'serve', ...(port !== null ? ['--port', String(port)] : []), `${CHILD_FLAG}${encodeChildSpec({ env: childEnv, log, start: startedHow })}`]
|
|
172
188
|
let started
|
|
173
189
|
try {
|
|
174
190
|
started = await start({ how, execPath, args, env, cwd })
|
|
@@ -198,6 +214,26 @@ export async function runDetach({
|
|
|
198
214
|
return fail('timeout', `the host did not answer within ${Math.round(timeoutMs / 1000)} s${why ? `:\n${why}` : ''}`)
|
|
199
215
|
}
|
|
200
216
|
|
|
217
|
+
/** Why the host was not started in the Windows sign-in, from `startInSignIn`'s answer, for serve.log and stderr */
|
|
218
|
+
export function signInMissed(s) {
|
|
219
|
+
if (s.reason === 'nosession') {
|
|
220
|
+
const n = s.desktops
|
|
221
|
+
const unread = s.unreadable ? `, ${s.unreadable} of them with an owner WMI would not tell` : ''
|
|
222
|
+
const seen =
|
|
223
|
+
typeof n !== 'number'
|
|
224
|
+
? 'no Explorer of this account in a desktop session'
|
|
225
|
+
: n === 0
|
|
226
|
+
? 'no Explorer in any desktop session'
|
|
227
|
+
: `none of the ${n} Explorer${n === 1 ? '' : 's'} in desktop sessions is this account's${unread}`
|
|
228
|
+
return `no Windows sign-in of this user was found (${seen})`
|
|
229
|
+
}
|
|
230
|
+
// It travels on the launcher's command line: bounded
|
|
231
|
+
if (s.reason === 'error') return `the sign-in task failed: ${String(s.message).slice(0, 300)}`
|
|
232
|
+
if (s.reason === 'off') return 'the sign-in task is turned off (CENTRALU_SIGNIN_TASK=off)'
|
|
233
|
+
if (s.reason === 'no_launcher') return 'there is no launcher in the data folder for the sign-in task to run'
|
|
234
|
+
return `the sign-in task was not used (${s.reason})`
|
|
235
|
+
}
|
|
236
|
+
|
|
201
237
|
/**
|
|
202
238
|
* The launcher `--detach` started: `runServe` writing to serve.log. The carried variables are
|
|
203
239
|
* already in `env` (centralu.mjs merges them before it looks for the host).
|
|
@@ -216,7 +252,8 @@ export async function runDetachedChild({ spec, ...opts }) {
|
|
|
216
252
|
} catch {
|
|
217
253
|
// no log: say() keeps stderr, which nobody reads; --detach then reports the start as never run
|
|
218
254
|
}
|
|
219
|
-
|
|
255
|
+
// One line saying how it was started and why (#1223), and what runs
|
|
256
|
+
say(`started in the background${spec.start ? ` ${spec.start}` : ''} (pid ${process.pid}, Node ${process.version})`)
|
|
220
257
|
try {
|
|
221
258
|
return await runServe({ ...opts, logFd: fd })
|
|
222
259
|
} catch (e) {
|
|
@@ -230,6 +267,10 @@ export async function runDetachedChild({ spec, ...opts }) {
|
|
|
230
267
|
* socket, and waited for (`stopServedHost` in serve-handover.mjs, which the hand-over into the sign-in shares, #1224).
|
|
231
268
|
*
|
|
232
269
|
* stdout: one line, `{ v: 1, stop: { ok, wasRunning, how?, message? } }`.
|
|
270
|
+
*
|
|
271
|
+
* A stop that went through is remembered in the data folder (`markStopped`, #1143), so a linked hub does not start the
|
|
272
|
+
* host again by itself; the next host that starts here removes the mark. Not for a hub's own stop, which starts the
|
|
273
|
+
* host again itself (an update, a rollback): the hub runs it with `CENTRALU_STOP_BY=hub` (links/tunnel.ts `stopCommand`)
|
|
233
274
|
*/
|
|
234
275
|
export async function runStop({
|
|
235
276
|
env,
|
|
@@ -242,5 +283,7 @@ export async function runStop({
|
|
|
242
283
|
write = (line) => process.stdout.write(line),
|
|
243
284
|
}) {
|
|
244
285
|
const dataDir = serveDataDir(env, home)
|
|
245
|
-
|
|
286
|
+
const stopped = await stopServedHost({ dataDir, entry, platform, timeoutMs, graceMs, kill })
|
|
287
|
+
if (stopped.ok && env.CENTRALU_STOP_BY !== 'hub') markStopped(dataDir, 'stop')
|
|
288
|
+
return answerLine(write, 'stop', stopped)
|
|
246
289
|
}
|
package/bin/serve-files.mjs
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { execFileSync } from 'node:child_process'
|
|
7
7
|
import { randomBytes } from 'node:crypto'
|
|
8
|
-
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeSync, closeSync } from 'node:fs'
|
|
8
|
+
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync, closeSync } from 'node:fs'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
|
-
import { isAlive, replaceFile, sleepSync } from './serve-util.mjs'
|
|
10
|
+
import { isAlive, renameRetrying, 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.
|
|
@@ -25,6 +25,73 @@ export const SERVE_LOG_FILE = 'serve.log'
|
|
|
25
25
|
/** The running launcher's pid and port, so `--stop` can find it. Removed when it exits */
|
|
26
26
|
export const SERVE_PID_FILE = 'serve.pid'
|
|
27
27
|
|
|
28
|
+
/** How many earlier starts' serve.log are kept, as serve.log.1 (the last) to serve.log.3 */
|
|
29
|
+
export const SERVE_LOGS_KEPT = 3
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Makes room for a new start's serve.log (#1143): the earlier ones move down a place, and the oldest past
|
|
33
|
+
* `SERVE_LOGS_KEPT` goes. A hub that starts a host again after it stopped makes at most three starts in ten minutes,
|
|
34
|
+
* so the log of the start that failed first is still here when it stops trying. The last one is moved aside with
|
|
35
|
+
* `renameRetrying` (a reader may hold the file on Windows, #1223); one that cannot be moved is removed rather than
|
|
36
|
+
* left to read as the new start's. Never fatal
|
|
37
|
+
*/
|
|
38
|
+
export function rotateServeLog(log, kept = SERVE_LOGS_KEPT, rename = renameRetrying) {
|
|
39
|
+
// No log to move aside, no room to make: a second call for the same start (the task's line removed before a WMI start)
|
|
40
|
+
if (!existsSync(log)) return
|
|
41
|
+
for (let i = kept - 1; i >= 1; i--) {
|
|
42
|
+
try {
|
|
43
|
+
renameSync(`${log}.${i}`, `${log}.${i + 1}`)
|
|
44
|
+
} catch {
|
|
45
|
+
// there was none, or a reader holds it: the older log stays where it is
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
rename(log, `${log}.1`)
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (e?.code === 'ENOENT') return
|
|
52
|
+
try {
|
|
53
|
+
rmSync(log, { force: true, maxRetries: 5 })
|
|
54
|
+
} catch {
|
|
55
|
+
// nothing more to do
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The person's stop, kept in the data folder (#1143): `centralu serve --stop` writes it (`by: 'stop'`), and so does a
|
|
62
|
+
* host that Centralu's own app stops for good when the person quits it there (`by: 'quit'`, the host's
|
|
63
|
+
* lifecycle/stopped-mark.ts, which names the same file). Every host that starts in the folder removes it.
|
|
64
|
+
* `--connection` reports it while no host runs, and a hub then starts none by itself. A hub's own stop (an update)
|
|
65
|
+
* leaves none: it says so with `CENTRALU_STOP_BY=hub`
|
|
66
|
+
*/
|
|
67
|
+
export const SERVE_STOPPED_FILE = 'stopped.json'
|
|
68
|
+
|
|
69
|
+
/** Writes the mark; never fatal, a stop that could not note itself still stopped */
|
|
70
|
+
export function markStopped(dataDir, by, now = Date.now()) {
|
|
71
|
+
try {
|
|
72
|
+
replaceFile(join(dataDir, SERVE_STOPPED_FILE), `${JSON.stringify({ v: 1, by, at: now })}\n`)
|
|
73
|
+
} catch {
|
|
74
|
+
// the folder is read-only or gone: nothing to remember it in
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The mark as `{ by, at }`, or null when there is none. One that does not parse is still a stop, by `stop`, at no time */
|
|
79
|
+
export function readStopped(dataDir) {
|
|
80
|
+
let text
|
|
81
|
+
try {
|
|
82
|
+
text = readFileSync(join(dataDir, SERVE_STOPPED_FILE), 'utf8')
|
|
83
|
+
} catch {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
let o = null
|
|
87
|
+
try {
|
|
88
|
+
o = JSON.parse(text)
|
|
89
|
+
} catch {
|
|
90
|
+
// half written, or written by hand
|
|
91
|
+
}
|
|
92
|
+
return { by: o?.by === 'quit' ? 'quit' : 'stop', at: typeof o?.at === 'number' && Number.isFinite(o.at) ? o.at : null }
|
|
93
|
+
}
|
|
94
|
+
|
|
28
95
|
/** The data folder: the host's own rule (`dataRoot()` in agent-host), CC_DATA_DIR or ~/.centralu */
|
|
29
96
|
export function serveDataDir(env, home) {
|
|
30
97
|
return env.CC_DATA_DIR || join(home, '.centralu')
|
package/bin/serve-host.mjs
CHANGED
|
@@ -34,7 +34,7 @@ export function protocolVersionOf(entry) {
|
|
|
34
34
|
* `install` (additive, the line stays `v: 1`) says what runs this command (`installInfo`); `detach`
|
|
35
35
|
* is added by `--detach` only.
|
|
36
36
|
*/
|
|
37
|
-
export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, autostart, detach, proof }) {
|
|
37
|
+
export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, autostart, detach, proof, stopped }) {
|
|
38
38
|
/*
|
|
39
39
|
* The options this serve takes that an older one refuses (#1223): a hub adds `--from <its name>` to `--detach` only
|
|
40
40
|
* when the line says so, since an older serve answers an option it does not know with an error. `window`: the
|
|
@@ -54,6 +54,8 @@ export function connectionLine({ port, token, version, protocolVersion, dataDir,
|
|
|
54
54
|
...(detach ? { detach } : {}),
|
|
55
55
|
// The running host proves it holds the token before the hub sends it (#838); absent: the hub sends hello as before
|
|
56
56
|
...(proof ? { proof: true } : {}),
|
|
57
|
+
// The person stopped the host here, and when (#1143, `readStopped`); absent: an older serve, or no such stop
|
|
58
|
+
...(stopped ? { stopped } : {}),
|
|
57
59
|
takes,
|
|
58
60
|
})
|
|
59
61
|
}
|
package/bin/serve-run.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { writeFileSync, writeSync } from 'node:fs'
|
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { createInterface } from 'node:readline'
|
|
6
6
|
import { isAlive, say } from './serve-util.mjs'
|
|
7
|
-
import { SERVE_STATE_FILE, SERVE_PID_FILE, serveDataDir, ensureServeState, recordServePort, rotateServeToken, heldHere, lockHolder, lockHolderPid, removeServePid } from './serve-files.mjs'
|
|
7
|
+
import { SERVE_STATE_FILE, SERVE_PID_FILE, readStopped, serveDataDir, ensureServeState, recordServePort, rotateServeToken, heldHere, lockHolder, 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
10
|
import { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, hostProvesOf, probeHost, serveFrom, takesTokenOnStdin, tokenLine } from './serve-host.mjs'
|
|
@@ -31,6 +31,9 @@ export async function describe({ env, dataDir, state, entry, version }) {
|
|
|
31
31
|
const probe = await probeHost({ port, token: state.token, protocolVersion: installed, prove: proof })
|
|
32
32
|
const protocolVersion = (probe.running ? probe.protocolVersion : undefined) ?? installed
|
|
33
33
|
if (protocolVersion === null) return null
|
|
34
|
+
const hostRunning = heldHere(probe, dataDir)
|
|
35
|
+
// The person stopped it here (#1143): said while no host runs, so a hub starts none by itself
|
|
36
|
+
const stopped = hostRunning ? null : readStopped(dataDir)
|
|
34
37
|
return {
|
|
35
38
|
proof,
|
|
36
39
|
port,
|
|
@@ -38,9 +41,10 @@ export async function describe({ env, dataDir, state, entry, version }) {
|
|
|
38
41
|
version: (probe.running ? probe.version : undefined) ?? version,
|
|
39
42
|
protocolVersion,
|
|
40
43
|
dataDir,
|
|
41
|
-
hostRunning
|
|
44
|
+
hostRunning,
|
|
42
45
|
install: installInfo(dataDir, env),
|
|
43
46
|
autostart: autostartInfo(dataDir, process.platform, env),
|
|
47
|
+
...(stopped ? { stopped } : {}),
|
|
44
48
|
}
|
|
45
49
|
}
|
|
46
50
|
|
package/bin/serve.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* (`serve-autostart.mjs`), Windows, WSL and PowerShell (`serve-windows.mjs`), and small helpers
|
|
25
25
|
* (`serve-util.mjs`).
|
|
26
26
|
*/
|
|
27
|
-
export { SERVE_STATE_FILE, SERVE_LOG_FILE, SERVE_PID_FILE, serveDataDir, readServeState, ensureServeState, recordServePort, rotateServeToken, heldHere, lockHolderIsAlive, processStartTime, lockHolder, lockHolderPid, readServePid } from './serve-files.mjs'
|
|
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
28
|
export { detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, windowsPowershell, ownLogon } from './serve-windows.mjs'
|
|
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'
|
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.31",
|
|
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.31",
|
|
42
|
+
"@centralu/linux-arm64": "0.1.0-beta.31",
|
|
43
|
+
"@centralu/linux-x64": "0.1.0-beta.31",
|
|
44
|
+
"@centralu/win32-x64": "0.1.0-beta.31"
|
|
45
45
|
}
|
|
46
46
|
}
|