centralu 0.1.0-beta.29 → 0.1.0-beta.30
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 +5 -3
- package/bin/serve-args.mjs +28 -9
- package/bin/serve-autostart.mjs +146 -35
- package/bin/serve-detach.mjs +41 -72
- package/bin/serve-files.mjs +16 -2
- package/bin/serve-handover.mjs +152 -0
- package/bin/serve-host.mjs +81 -5
- package/bin/serve-run.mjs +12 -11
- package/bin/serve-window.mjs +112 -0
- package/bin/serve-windows.mjs +15 -0
- package/bin/serve.mjs +6 -4
- package/package.json +5 -5
package/bin/centralu.mjs
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
runDetachedChild,
|
|
54
54
|
runServe,
|
|
55
55
|
runStop,
|
|
56
|
+
runWindow,
|
|
56
57
|
SERVE_HELP,
|
|
57
58
|
} from './serve.mjs'
|
|
58
59
|
import { swapInto } from './swap.mjs'
|
|
@@ -632,7 +633,7 @@ switch (cmd) {
|
|
|
632
633
|
* off|status` and `--rotate-token` start no host and keep working, so the hub can still ask this
|
|
633
634
|
* machine what Node it has.
|
|
634
635
|
*/
|
|
635
|
-
const startsHost = args.mode === 'serve' || args.mode === 'detach' || (args.mode === 'autostart' && args.autostart === 'on')
|
|
636
|
+
const startsHost = args.mode === 'serve' || args.mode === 'detach' || args.mode === 'window' || (args.mode === 'autostart' && args.autostart === 'on')
|
|
636
637
|
if (startsHost && belowFloor(process.versions.node, NODE_FLOOR)) {
|
|
637
638
|
const message = oldNodeSentence(process.versions.node, NODE_FLOOR)
|
|
638
639
|
console.error(`centralu serve: ${message}`)
|
|
@@ -655,11 +656,12 @@ switch (cmd) {
|
|
|
655
656
|
// realpath: npm's bin is a symlink to this file, and the launcher must not depend on the link
|
|
656
657
|
const opts = { env: process.env, home: HOME, entry: requireHostEntry(), version: pkg.version, cliPath: realpathSync(process.argv[1]) }
|
|
657
658
|
if (args.mode === 'connection') process.exitCode = await printConnection(opts)
|
|
658
|
-
else if (args.mode === 'detach') process.exitCode = await runDetach({ ...opts, port: args.port })
|
|
659
|
+
else if (args.mode === 'detach') process.exitCode = await runDetach({ ...opts, port: args.port, from: args.from })
|
|
659
660
|
else if (args.mode === 'stop') process.exitCode = await runStop(opts)
|
|
661
|
+
else if (args.mode === 'window') process.exitCode = await runWindow(opts)
|
|
660
662
|
else if (args.mode === 'autostart') process.exitCode = await runAutostart({ ...opts, action: args.autostart })
|
|
661
663
|
else if (spec) process.exitCode = await runDetachedChild({ ...opts, spec, port: args.port })
|
|
662
|
-
else process.exitCode = await runServe({ ...opts, port: args.port })
|
|
664
|
+
else process.exitCode = await runServe({ ...opts, port: args.port, from: args.from })
|
|
663
665
|
} catch (e) {
|
|
664
666
|
// A state file that cannot be read or written: one sentence, not a stack, and nothing on stdout
|
|
665
667
|
console.error(`centralu serve: ${e?.message ?? e}`)
|
package/bin/serve-args.mjs
CHANGED
|
@@ -7,13 +7,19 @@ export const SERVE_HELP = `centralu serve: run the Centralu host on this machine
|
|
|
7
7
|
centralu serve --port <n> ... on that port (kept for the next start; default ${DEFAULT_SERVE_PORT})
|
|
8
8
|
centralu serve --detach Start it in the background, outside this session; return once it answers
|
|
9
9
|
centralu serve --stop Ask the running host to stop, and wait until it has
|
|
10
|
+
centralu serve --window What the Centralu window on Windows runs here: start the host if
|
|
11
|
+
none runs, print its port and token as a ready line, stay until
|
|
12
|
+
stdin ends, and leave the host running
|
|
10
13
|
centralu serve --connection Print how to reach it as one JSON line, then exit
|
|
11
14
|
centralu serve --rotate-token Replace the token (a running serve keeps the old one until restarted)
|
|
12
|
-
centralu serve --
|
|
15
|
+
centralu serve --from <hub> With serve or --detach: the name of the computer whose link asks,
|
|
16
|
+
said by a second Centralu on this data folder when it is refused
|
|
17
|
+
centralu serve --autostart on|off|remove|status
|
|
13
18
|
Start it when this machine starts (Linux: a systemd user unit;
|
|
14
|
-
Windows and WSL: a scheduled task at sign-in), stop doing that
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
Windows and WSL: a scheduled task at sign-in), stop doing that
|
|
20
|
+
(Windows keeps the task, without its trigger, for --detach),
|
|
21
|
+
remove the entry altogether, or say whether it does. None of them
|
|
22
|
+
starts or stops a host that runs now
|
|
17
23
|
|
|
18
24
|
The data folder is ~/.centralu (CC_DATA_DIR overrides it). The app on another computer reaches
|
|
19
25
|
this host through an SSH local forward, and starts it with --detach when it is not running.
|
|
@@ -25,11 +31,11 @@ paths, for SSH sessions whose PATH has neither npm's global folder nor node. Not
|
|
|
25
31
|
from an install the app made over SSH, which has a launcher of its own.`
|
|
26
32
|
|
|
27
33
|
/** The flags that each make serve a different command */
|
|
28
|
-
const MODE_FLAGS = { '--connection': 'connection', '--rotate-token': 'rotate', '--detach': 'detach', '--stop': 'stop' }
|
|
34
|
+
const MODE_FLAGS = { '--connection': 'connection', '--rotate-token': 'rotate', '--detach': 'detach', '--stop': 'stop', '--window': 'window' }
|
|
29
35
|
const FLAG_OF = { ...Object.fromEntries(Object.entries(MODE_FLAGS).map(([f, m]) => [m, f])), autostart: '--autostart' }
|
|
30
36
|
|
|
31
37
|
/** What `--autostart` takes */
|
|
32
|
-
export const AUTOSTART_ACTIONS = ['on', 'off', 'status']
|
|
38
|
+
export const AUTOSTART_ACTIONS = ['on', 'off', 'remove', 'status']
|
|
33
39
|
|
|
34
40
|
/**
|
|
35
41
|
* The argument a detached launcher is started with: what it needs from the `--detach` that started
|
|
@@ -38,6 +44,12 @@ export const AUTOSTART_ACTIONS = ['on', 'off', 'status']
|
|
|
38
44
|
*/
|
|
39
45
|
export const CHILD_FLAG = '--detached-child='
|
|
40
46
|
|
|
47
|
+
/**
|
|
48
|
+
* What `--from` takes (#1223): a hub's name, short and of characters that cannot break the sentence a refused host says
|
|
49
|
+
* it in, nor a command line. The hub sends its own host name, cleaned the same way (`hubNameForRemote`, links/tunnel.ts)
|
|
50
|
+
*/
|
|
51
|
+
export const FROM_RE = /^[A-Za-z0-9._-]{1,64}$/
|
|
52
|
+
|
|
41
53
|
/**
|
|
42
54
|
* What the arguments after `serve` ask for: `{ mode, port }` (plus `child` for a detached launcher,
|
|
43
55
|
* `autostart` for `--autostart`) or `{ error }`.
|
|
@@ -53,11 +65,12 @@ export function parseServeArgs(argv) {
|
|
|
53
65
|
let help = false
|
|
54
66
|
let child = null
|
|
55
67
|
let autostart = null
|
|
68
|
+
let from = null
|
|
56
69
|
for (let i = 0; i < argv.length; i++) {
|
|
57
70
|
const a = argv[i]
|
|
58
71
|
if (a === '--autostart' || a.startsWith('--autostart=')) {
|
|
59
72
|
const raw = a === '--autostart' ? argv[++i] : a.slice('--autostart='.length)
|
|
60
|
-
if (!AUTOSTART_ACTIONS.includes(raw)) return { error: `--autostart needs on, off or status (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
|
|
73
|
+
if (!AUTOSTART_ACTIONS.includes(raw)) return { error: `--autostart needs on, off, remove or status (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
|
|
61
74
|
if (mode !== 'serve' && mode !== 'autostart') return { error: `${FLAG_OF[mode]} and --autostart are separate commands; pass one` }
|
|
62
75
|
mode = 'autostart'
|
|
63
76
|
autostart = raw
|
|
@@ -72,22 +85,28 @@ export function parseServeArgs(argv) {
|
|
|
72
85
|
const n = parsePort(raw)
|
|
73
86
|
if (n === null) return { error: `--port needs a port number between 1 and 65535 (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
|
|
74
87
|
port = n
|
|
88
|
+
} else if (a === '--from' || a.startsWith('--from=')) {
|
|
89
|
+
const raw = a === '--from' ? argv[++i] : a.slice('--from='.length)
|
|
90
|
+
if (typeof raw !== 'string' || !FROM_RE.test(raw)) return { error: `--from needs a computer's name: letters, digits, '.', '_' and '-' (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
|
|
91
|
+
from = raw
|
|
75
92
|
} else return { error: `unknown option for serve: ${a}\n\n${SERVE_HELP}` }
|
|
76
93
|
}
|
|
77
94
|
if (help) return { mode: 'help', port }
|
|
95
|
+
if (from !== null && mode !== 'serve' && mode !== 'detach') return { error: `--from goes with \`centralu serve\` and --detach, which start a host; not with ${FLAG_OF[mode]}` }
|
|
78
96
|
if (child !== null) {
|
|
79
97
|
if (mode !== 'serve') return { error: `${CHILD_FLAG.slice(0, -1)} is how --detach starts serve, not an option of ${FLAG_OF[mode]}` }
|
|
80
|
-
return { mode, port, child }
|
|
98
|
+
return { mode, port, child, ...(from !== null ? { from } : {}) }
|
|
81
99
|
}
|
|
82
100
|
if ((mode === 'connection' || mode === 'rotate') && port !== null) {
|
|
83
101
|
return { error: '--port goes with `centralu serve`; `--connection` reports the port serve listens on' }
|
|
84
102
|
}
|
|
85
103
|
if (mode === 'stop' && port !== null) return { error: '--stop stops the serve that is running, on the port it recorded; it takes no --port' }
|
|
104
|
+
if (mode === 'window' && port !== null) return { error: '--window uses the serve that runs, or starts one on the port it recorded; it takes no --port' }
|
|
86
105
|
if (mode === 'autostart') {
|
|
87
106
|
if (port !== null) return { error: '--autostart takes no --port: the entry it writes starts serve on the port serve recorded' }
|
|
88
107
|
return { mode, port, autostart }
|
|
89
108
|
}
|
|
90
|
-
return { mode, port }
|
|
109
|
+
return { mode, port, ...(from !== null ? { from } : {}) }
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
function parsePort(raw) {
|
package/bin/serve-autostart.mjs
CHANGED
|
@@ -69,12 +69,23 @@ export function pathKey(p, platform) {
|
|
|
69
69
|
* this Node's `join`: the folder may be written with either separator (`CC_DATA_DIR=C:/x` on Windows),
|
|
70
70
|
* and Windows paths do not differ by case, so `C:\Users\Me\.centralu` is the default folder too.
|
|
71
71
|
*/
|
|
72
|
-
export function autostartName(how, { dataDir, home, env, platform = process.platform }) {
|
|
72
|
+
export function autostartName(how, { dataDir, home, env, platform = process.platform, user = null }) {
|
|
73
73
|
const key = pathKey(dataDir, platform)
|
|
74
74
|
const own = key === pathKey(`${home}/.centralu`, platform) ? '' : createHash('sha256').update(key).digest('hex').slice(0, 8)
|
|
75
75
|
if (how === 'systemd') return `centralu-serve${own ? `-${own}` : ''}.service`
|
|
76
76
|
const distro = env.WSL_DISTRO_NAME && wslDistro(env.WSL_DISTRO_NAME)
|
|
77
|
-
|
|
77
|
+
/*
|
|
78
|
+
* The user too (#1224 review): tasks in Task Scheduler's root folder are one list for every account, and an
|
|
79
|
+
* administrator's ssh logon sees the others'. Without it, a second account's `--detach` on a shared PC would find and
|
|
80
|
+
* start the first account's task. Characters a task name cannot hold become `-`
|
|
81
|
+
*/
|
|
82
|
+
const who = user ? ` (${String(user).replace(/[\\/:*?"<>|]/g, '-')})` : ''
|
|
83
|
+
return `Centralu host${who}${distro ? ` (WSL ${distro})` : ''}${own ? ` ${own}` : ''}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The task's name before the user was part of it: an earlier version's entry, removed when this user's (`taskScript`) */
|
|
87
|
+
export function legacyTaskName({ dataDir, home, env, platform = process.platform }) {
|
|
88
|
+
return autostartName('task', { dataDir, home, env, platform })
|
|
78
89
|
}
|
|
79
90
|
|
|
80
91
|
/**
|
|
@@ -154,34 +165,104 @@ export function taskActionScript({ how, launcher, env, distro = null, user = nul
|
|
|
154
165
|
const TASK_WORD = 'CENTRALU-AUTOSTART'
|
|
155
166
|
|
|
156
167
|
/**
|
|
157
|
-
* The PowerShell that registers, removes or reads the scheduled task, printing one `CENTRALU-AUTOSTART`
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
168
|
+
* The PowerShell that registers, changes, removes or reads the scheduled task, printing one `CENTRALU-AUTOSTART` line.
|
|
169
|
+
* Registered for this user only, run only while they are signed in (`Interactive`, `Limited`): the kind of task a user
|
|
170
|
+
* may create without an administrator. On a laptop, it runs on battery too, and with no time limit (the default ends a
|
|
171
|
+
* task after 72 hours).
|
|
172
|
+
*
|
|
173
|
+
* on the task with an `AtLogOn` trigger: the host starts at sign-in
|
|
174
|
+
* off the same task without its trigger (#1224): kept for `serve --detach`, which starts the host in the
|
|
175
|
+
* person's sign-in through it; a task without a trigger reads as autostart off (`status` says `notrigger`)
|
|
176
|
+
* remove the task gone (uninstall)
|
|
177
|
+
* status `state none`, `state notrigger` or the task's state
|
|
178
|
+
* signin `serve --detach`'s start (#1224): when this user has a desktop, the task is checked and started. It is
|
|
179
|
+
* registered (without a trigger) when missing, and registered again when its action or its principal is not
|
|
180
|
+
* what this serve would write (a launcher that moved, another install's data folder, a task some other
|
|
181
|
+
* account wrote under this name): Start-ScheduledTask would otherwise run something else, or as someone else.
|
|
182
|
+
* A trigger is kept only on a task that was this user's own
|
|
183
|
+
*
|
|
184
|
+
* A task whose principal is `Interactive` runs in that user's session with their sign-in's token, also when
|
|
185
|
+
* Start-ScheduledTask is called from an ssh session. Measured on a linked Windows 11 PC (2026-10-19): a task started
|
|
186
|
+
* that way from the hub's ssh logon ran in session 1 with an interactive logon at medium integrity and saw the user's
|
|
187
|
+
* Credential Manager entries, where the ssh logon's own processes saw none.
|
|
188
|
+
*
|
|
189
|
+
* `legacy` is the name before the user was part of it (`autostartName`): this user's task of that name is removed by
|
|
190
|
+
* `on`, `off`, `remove` and `signin`, and read by `status`, so an entry an earlier version wrote neither lingers nor
|
|
191
|
+
* starts twice.
|
|
161
192
|
*/
|
|
162
|
-
export function taskScript(action, { name, run = '' }) {
|
|
193
|
+
export function taskScript(action, { name, run = '', legacy = null }) {
|
|
163
194
|
const head = ["$ErrorActionPreference = 'Stop'", PS_MODULE_PATH, '[Console]::OutputEncoding = [Text.Encoding]::UTF8', `$name = ${psText(name)}`]
|
|
164
195
|
const fail = `} catch { "${TASK_WORD}-ERROR $($_.Exception.Message -replace '\\s+', ' ')" }`
|
|
165
196
|
const find = "$t = Get-ScheduledTask -TaskName $name -TaskPath '\\' -ErrorAction SilentlyContinue"
|
|
166
|
-
|
|
197
|
+
// Whether a task's principal is this user's, as this serve registers it. Task Scheduler gives the id with or without the domain
|
|
198
|
+
const mine = [
|
|
199
|
+
'$me = if ($env:USERDOMAIN) { "$env:USERDOMAIN\\$env:USERNAME" } else { $env:USERNAME }',
|
|
200
|
+
"function Test-Mine($x) { $x -and $x.Principal -and ($x.Principal.UserId -eq $me -or $x.Principal.UserId -eq $env:USERNAME) -and \"$($x.Principal.LogonType)\" -eq 'Interactive' -and \"$($x.Principal.RunLevel)\" -eq 'Limited' }",
|
|
201
|
+
]
|
|
202
|
+
const old = legacy
|
|
203
|
+
? [`$legacy = ${psText(legacy)}`, "$old = Get-ScheduledTask -TaskName $legacy -TaskPath '\\' -ErrorAction SilentlyContinue"]
|
|
204
|
+
: ['$old = $null']
|
|
205
|
+
const dropOld = "if ($old -and (Test-Mine $old)) { Unregister-ScheduledTask -TaskName $legacy -TaskPath '\\' -Confirm:$false }"
|
|
206
|
+
if (action === 'status') {
|
|
207
|
+
return [
|
|
208
|
+
...head,
|
|
209
|
+
'try {',
|
|
210
|
+
...[...mine, ...old, find, 'if (-not $t -and (Test-Mine $old)) { $t = $old }'].map((l) => ` ${l}`),
|
|
211
|
+
` if (-not $t) { '${TASK_WORD} state none' } elseif (@($t.Triggers | Where-Object { $_ }).Count -eq 0) { '${TASK_WORD} state notrigger' } else { "${TASK_WORD} state $($t.State)" }`,
|
|
212
|
+
fail,
|
|
213
|
+
].join('\n')
|
|
214
|
+
}
|
|
215
|
+
if (action === 'remove') {
|
|
216
|
+
return [...head, 'try {', ...[...mine, ...old, find, "if ($t) { Unregister-ScheduledTask -TaskName $name -TaskPath '\\' -Confirm:$false }", dropOld].map((l) => ` ${l}`), ` '${TASK_WORD} off'`, fail].join('\n')
|
|
217
|
+
}
|
|
167
218
|
if (action === 'off') {
|
|
168
|
-
|
|
219
|
+
// The task stays, with what it runs and as whom; only the trigger goes
|
|
220
|
+
const keep = "if ($t -and @($t.Triggers | Where-Object { $_ }).Count -gt 0) { Register-ScheduledTask -TaskName $name -TaskPath '\\' -Action $t.Actions -Principal $t.Principal -Settings $t.Settings -Description $t.Description -Force | Out-Null }"
|
|
221
|
+
return [...head, 'try {', ...[...mine, ...old, find, keep, dropOld].map((l) => ` ${l}`), ` '${TASK_WORD} off'`, fail].join('\n')
|
|
169
222
|
}
|
|
170
223
|
const encoded = encodedCommand(run)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
'
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
'
|
|
178
|
-
'
|
|
179
|
-
'
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
]
|
|
224
|
+
const want = [
|
|
225
|
+
"$exe = Join-Path $env:SystemRoot 'System32\\WindowsPowerShell\\v1.0\\powershell.exe'",
|
|
226
|
+
`$arg = '-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encoded}'`,
|
|
227
|
+
'$user = $me',
|
|
228
|
+
]
|
|
229
|
+
const register = (trigger) => [
|
|
230
|
+
'$a = New-ScheduledTaskAction -Execute $exe -Argument $arg',
|
|
231
|
+
'$p = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited',
|
|
232
|
+
'$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew',
|
|
233
|
+
`$d = ${psText(trigger === 'logon' ? 'Starts the Centralu host (centralu serve) when you sign in. Written by centralu serve --autostart on.' : 'Starts the Centralu host (centralu serve) in your sign-in when a linked computer asks for it. Written by centralu serve --detach; centralu serve --autostart remove removes it.')}`,
|
|
234
|
+
trigger === 'logon'
|
|
235
|
+
? "Register-ScheduledTask -TaskName $name -TaskPath '\\' -Action $a -Trigger (New-ScheduledTaskTrigger -AtLogOn -User $user) -Principal $p -Settings $s -Description $d -Force | Out-Null"
|
|
236
|
+
: "if (@($keep).Count -gt 0) { Register-ScheduledTask -TaskName $name -TaskPath '\\' -Action $a -Trigger $keep -Principal $p -Settings $s -Description $d -Force | Out-Null } else { Register-ScheduledTask -TaskName $name -TaskPath '\\' -Action $a -Principal $p -Settings $s -Description $d -Force | Out-Null }",
|
|
237
|
+
]
|
|
238
|
+
if (action === 'signin') {
|
|
239
|
+
/*
|
|
240
|
+
* 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 processes
|
|
242
|
+
* and reads the same in every language
|
|
243
|
+
*/
|
|
244
|
+
return [
|
|
245
|
+
...head,
|
|
246
|
+
'try {',
|
|
247
|
+
...mine.map((l) => ` ${l}`),
|
|
248
|
+
" $desk = @(Get-CimInstance Win32_Process -Filter \"Name='explorer.exe'\" | Where-Object { $_.SessionId -gt 0 } | Where-Object { $o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner; $o.ReturnValue -eq 0 -and $o.User -eq $env:USERNAME -and (-not $env:USERDOMAIN -or $o.Domain -eq $env:USERDOMAIN) })",
|
|
249
|
+
` if ($desk.Count -eq 0) { '${TASK_WORD} nosession' } else {`,
|
|
250
|
+
...[...want, ...old, find, dropOld].map((l) => ` ${l}`),
|
|
251
|
+
' $acts = @(if ($t) { $t.Actions })',
|
|
252
|
+
' $same = (Test-Mine $t) -and $acts.Count -eq 1 -and $acts[0].Execute -eq $exe -and $acts[0].Arguments -eq $arg',
|
|
253
|
+
' if (-not $same) {',
|
|
254
|
+
// A trigger is this user's own choice (autostart on): kept only when the task was theirs
|
|
255
|
+
' $keep = @(if (Test-Mine $t) { $t.Triggers | Where-Object { $_ } })',
|
|
256
|
+
...register('keep').map((l) => ` ${l}`),
|
|
257
|
+
` '${TASK_WORD}-NOTE registered'`,
|
|
258
|
+
' }',
|
|
259
|
+
" Start-ScheduledTask -TaskName $name -TaskPath '\\'",
|
|
260
|
+
` '${TASK_WORD} started'`,
|
|
261
|
+
' }',
|
|
262
|
+
fail,
|
|
263
|
+
].join('\n')
|
|
264
|
+
}
|
|
265
|
+
return [...head, 'try {', ...[...mine, ...want, ...old, ...register('logon'), dropOld].map((l) => ` ${l}`), ` '${TASK_WORD} on'`, fail].join('\n')
|
|
185
266
|
}
|
|
186
267
|
|
|
187
268
|
/** What a task script printed: `{ ok: true, word }` (`on`, `off`, or the state after `state`) or `{ ok: false, message }` */
|
|
@@ -256,7 +337,7 @@ async function systemdAutostart(action, { dataDir, env, home, name, run, user })
|
|
|
256
337
|
else clearAutostartMark(dataDir)
|
|
257
338
|
return { ...base, on, linger }
|
|
258
339
|
}
|
|
259
|
-
if (action === 'off') {
|
|
340
|
+
if (action === 'off' || action === 'remove') {
|
|
260
341
|
if (existsSync(file)) {
|
|
261
342
|
await ctl('disable', name)
|
|
262
343
|
rmSync(file, { force: true })
|
|
@@ -289,39 +370,69 @@ async function systemdAutostart(action, { dataDir, env, home, name, run, user })
|
|
|
289
370
|
return { ...base, on: true, linger, ...(linger === true ? {} : { message: lingerMessage(user) }) }
|
|
290
371
|
}
|
|
291
372
|
|
|
292
|
-
async function taskAutostart(action, { dataDir, env, name, run, user, platform, interop }) {
|
|
373
|
+
async function taskAutostart(action, { dataDir, env, home, name, run, user, platform, interop }) {
|
|
374
|
+
const legacy = legacyTaskName({ dataDir, home, env, platform })
|
|
293
375
|
const detach = detachHow(platform, env, interop)
|
|
294
376
|
const ps = windowsPowershell(detach, env)
|
|
295
377
|
const base = { ok: true, how: 'task', linger: null }
|
|
296
378
|
if (!ps) return { ...base, ok: false, on: false, message: 'Windows PowerShell is not reachable from this WSL distro' }
|
|
297
379
|
const ask = (script) => askPowershell(ps, script, env, parseTaskAnswer, run)
|
|
298
380
|
if (action === 'status') {
|
|
299
|
-
const a = await ask(taskScript('status', { name }))
|
|
381
|
+
const a = await ask(taskScript('status', { name, legacy }))
|
|
300
382
|
if (!a.ok) return { ...base, ok: false, on: readAutostartMark(dataDir)?.how === 'task', message: `Task Scheduler did not answer: ${a.message}` }
|
|
301
|
-
|
|
383
|
+
// A task without its trigger is the one `--detach` starts in the sign-in (#1224): no start at sign-in
|
|
384
|
+
const on = a.word !== 'none' && a.word !== 'Disabled' && a.word !== 'notrigger'
|
|
302
385
|
if (on) writeAutostartMark(dataDir, { how: 'task', name, linger: null })
|
|
303
386
|
else clearAutostartMark(dataDir)
|
|
304
387
|
return { ...base, on }
|
|
305
388
|
}
|
|
306
|
-
if (action === 'off') {
|
|
307
|
-
|
|
308
|
-
|
|
389
|
+
if (action === 'off' || action === 'remove') {
|
|
390
|
+
// `off` keeps the task without its trigger, for `--detach`'s start in the sign-in; `remove` (uninstall) removes it
|
|
391
|
+
const a = await ask(taskScript(action, { name, legacy }))
|
|
392
|
+
if (!a.ok) return { ...base, ok: false, on: true, message: `Task Scheduler did not ${action === 'off' ? 'change' : 'remove'} the task "${name}": ${a.message}` }
|
|
309
393
|
clearAutostartMark(dataDir)
|
|
310
394
|
return { ...base, on: false }
|
|
311
395
|
}
|
|
312
396
|
const launcher = autostartLauncher(dataDir, platform)
|
|
313
397
|
if (!launcher) return { ...base, ok: false, on: false, message: `there is no Centralu launcher in ${dataDir} to start at sign-in` }
|
|
314
398
|
const runScript = taskActionScript({ how: detach, launcher, env, distro: env.WSL_DISTRO_NAME ?? null, user })
|
|
315
|
-
const a = await ask(taskScript('on', { name, run: runScript }))
|
|
399
|
+
const a = await ask(taskScript('on', { name, run: runScript, legacy }))
|
|
316
400
|
if (!a.ok) return { ...base, ok: false, on: false, message: `Task Scheduler did not take the task: ${a.message}` }
|
|
317
401
|
writeAutostartMark(dataDir, { how: 'task', name, linger: null })
|
|
318
402
|
return { ...base, on: true }
|
|
319
403
|
}
|
|
320
404
|
|
|
321
405
|
/**
|
|
322
|
-
*
|
|
406
|
+
* Starts this data folder's host in the person's Windows sign-in, when someone is signed in (#1224): the task
|
|
407
|
+
* `--autostart` writes, registered without its trigger when it is not there, started with Start-ScheduledTask. Called
|
|
408
|
+
* by `serve --detach` before it falls back to WMI, whose process keeps the caller's logon: from an ssh session that is a
|
|
409
|
+
* network logon, which cannot open the user's Credential Manager vault, runs elevated for an administrator (#702) and
|
|
410
|
+
* opens what it shows in session 0, where nobody sees it.
|
|
411
|
+
*
|
|
412
|
+
* `{ started: true, name }`, or `{ started: false, reason, message? }`: `nosession` (nobody is signed in here),
|
|
413
|
+
* `off` (`CENTRALU_SIGNIN_TASK=off`, for a person who wants no task, and for tests on a machine with a desktop),
|
|
414
|
+
* `not_windows`, `no_launcher`, `error`. `run` is a parameter so tests never reach the real Task Scheduler.
|
|
415
|
+
*/
|
|
416
|
+
export async function startInSignIn({ dataDir, env, home, platform = process.platform, run = runCaptured, user = env.USERNAME || userInfo().username }) {
|
|
417
|
+
if (platform !== 'win32' || env.WSL_DISTRO_NAME) return { started: false, reason: 'not_windows' }
|
|
418
|
+
if (env.CENTRALU_SIGNIN_TASK === 'off') return { started: false, reason: 'off' }
|
|
419
|
+
const launcher = autostartLauncher(dataDir, platform)
|
|
420
|
+
if (!launcher) return { started: false, reason: 'no_launcher' }
|
|
421
|
+
const ps = windowsPowershell('wmi', env)
|
|
422
|
+
if (!ps) return { started: false, reason: 'error', message: 'Windows PowerShell is not reachable' }
|
|
423
|
+
const name = autostartName('task', { dataDir, home, env, platform, user })
|
|
424
|
+
const runScript = taskActionScript({ how: 'wmi', launcher, env })
|
|
425
|
+
const a = await askPowershell(ps, taskScript('signin', { name, run: runScript, legacy: legacyTaskName({ dataDir, home, env, platform }) }), env, parseTaskAnswer, run)
|
|
426
|
+
if (!a.ok) return { started: false, reason: 'error', message: a.message }
|
|
427
|
+
if (a.word === 'started') return { started: true, name }
|
|
428
|
+
if (a.word === 'nosession') return { started: false, reason: 'nosession' }
|
|
429
|
+
return { started: false, reason: 'error', message: `Task Scheduler answered ${a.word}` }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* `centralu serve --autostart on|off|remove|status` (docs/plans/remote-hub.md §10.4, owner decision 4: per
|
|
323
434
|
* machine, off by default). The hub runs it over ssh for the Settings → Machines toggle, and its
|
|
324
|
-
* uninstall runs `off
|
|
435
|
+
* uninstall runs `remove` (`off` on a serve that predates it). Turning it on or off never starts or stops a host: one runs already when the
|
|
325
436
|
* hub asks (the link started it), and the entry is for the next boot.
|
|
326
437
|
*
|
|
327
438
|
* stdout: one line, `{ v: 1, autostart: { ok, on, how, linger, message? } }`; `how` null where this
|
|
@@ -346,10 +457,10 @@ export async function runAutostart({
|
|
|
346
457
|
const report = (a) => answerLine(write, 'autostart', a)
|
|
347
458
|
if (how === null) {
|
|
348
459
|
clearAutostartMark(dataDir)
|
|
349
|
-
return report({ ok: action !== 'on', on: false, how: null, linger: null, ...(action === 'off' ? {} : { message: autostartUnsupported(platform, env) }) })
|
|
460
|
+
return report({ ok: action !== 'on', on: false, how: null, linger: null, ...(action === 'off' || action === 'remove' ? {} : { message: autostartUnsupported(platform, env) }) })
|
|
350
461
|
}
|
|
351
462
|
try {
|
|
352
|
-
const name = autostartName(how, { dataDir, home, env, platform })
|
|
463
|
+
const name = autostartName(how, { dataDir, home, env, platform, user })
|
|
353
464
|
const opts = { dataDir, env, home, name, run, user, platform, interop }
|
|
354
465
|
return report(how === 'systemd' ? await systemdAutostart(action, opts) : await taskAutostart(action, opts))
|
|
355
466
|
} catch (e) {
|
package/bin/serve-detach.mjs
CHANGED
|
@@ -6,12 +6,13 @@ import { spawn } from 'node:child_process'
|
|
|
6
6
|
import { existsSync, openSync, renameSync, writeSync } from 'node:fs'
|
|
7
7
|
import { userInfo } from 'node:os'
|
|
8
8
|
import { join } from 'node:path'
|
|
9
|
-
import { answerLine, isAlive, say, sayTo, runCaptured, tail, sleep
|
|
10
|
-
import { SERVE_LOG_FILE, serveDataDir,
|
|
9
|
+
import { answerLine, isAlive, say, sayTo, runCaptured, tail, sleep } from './serve-util.mjs'
|
|
10
|
+
import { SERVE_LOG_FILE, serveDataDir, ensureServeState } from './serve-files.mjs'
|
|
11
11
|
import { askPowershell, detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, windowsPowershell, windowsTool, wslDistro, wslUser } from './serve-windows.mjs'
|
|
12
|
-
import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
|
|
13
12
|
import { CHILD_FLAG } from './serve-args.mjs'
|
|
14
|
-
import {
|
|
13
|
+
import { startInSignIn } from './serve-autostart.mjs'
|
|
14
|
+
import { stopServedHost } from './serve-handover.mjs'
|
|
15
|
+
import { connectionLine, serveFrom } from './serve-host.mjs'
|
|
15
16
|
import { describe, runServe, serveFolder } from './serve-run.mjs'
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -19,7 +20,7 @@ import { describe, runServe, serveFolder } from './serve-run.mjs'
|
|
|
19
20
|
* does not inherit this environment: it gets the account's default one. Everything else it needs
|
|
20
21
|
* (the token, the port) is in the data folder. Never a secret.
|
|
21
22
|
*/
|
|
22
|
-
export const CARRIED_ENV = ['CC_DATA_DIR', 'CENTRALU_HOST_ENTRY', 'CENTRALU_MANAGED']
|
|
23
|
+
export const CARRIED_ENV = ['CC_DATA_DIR', 'CENTRALU_HOST_ENTRY', 'CENTRALU_MANAGED', 'CENTRALU_STARTED_FROM']
|
|
23
24
|
|
|
24
25
|
export function encodeChildSpec({ env, log }) {
|
|
25
26
|
const carried = {}
|
|
@@ -111,11 +112,14 @@ export async function runDetach({
|
|
|
111
112
|
version,
|
|
112
113
|
cliPath,
|
|
113
114
|
port = null,
|
|
115
|
+
from = null,
|
|
114
116
|
platform = process.platform,
|
|
115
117
|
execPath = process.execPath,
|
|
116
118
|
cwd = process.cwd(),
|
|
117
119
|
start = startDetached,
|
|
118
120
|
how = detachHow(platform, env),
|
|
121
|
+
signIn = startInSignIn,
|
|
122
|
+
signInMs = 20_000,
|
|
119
123
|
timeoutMs = 30_000,
|
|
120
124
|
noStartMs = 10_000,
|
|
121
125
|
write = (line) => process.stdout.write(line),
|
|
@@ -123,14 +127,37 @@ export async function runDetach({
|
|
|
123
127
|
// Created here, so the token exists before the launcher reads it and `answered` reads it again
|
|
124
128
|
const { dataDir } = serveFolder({ env, home, cliPath, platform })
|
|
125
129
|
const fail = (reason, message) => answerLine(write, 'detach', { ok: false, how, reason, message })
|
|
126
|
-
const answered = async (already) => {
|
|
130
|
+
const answered = async (already, as = how) => {
|
|
127
131
|
const d = await describe({ env, dataDir, state: ensureServeState(dataDir, platform), entry, version })
|
|
128
132
|
if (!d?.hostRunning) return false
|
|
129
|
-
write(`${connectionLine({ ...d, detach: { ok: true, how, already } })}\n`)
|
|
133
|
+
write(`${connectionLine({ ...d, detach: { ok: true, how: as, already } })}\n`)
|
|
130
134
|
return true
|
|
131
135
|
}
|
|
132
136
|
if (await answered(true)) return 0
|
|
133
137
|
|
|
138
|
+
/*
|
|
139
|
+
* Windows: in the person's sign-in when someone is signed in (#1224), through the scheduled task, and through WMI only
|
|
140
|
+
* when nobody is. WMI's process keeps this ssh session's network logon, where Git cannot use the credentials saved in
|
|
141
|
+
* 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.
|
|
143
|
+
*/
|
|
144
|
+
if (how === 'wmi') {
|
|
145
|
+
const s = await signIn({ dataDir, env, home, platform })
|
|
146
|
+
if (s.started) {
|
|
147
|
+
say(`started centralu serve in the Windows sign-in (the scheduled task "${s.name}"); waiting for its host`)
|
|
148
|
+
const t0 = Date.now()
|
|
149
|
+
while (Date.now() - t0 < signInMs) {
|
|
150
|
+
if (await answered(false, 'task')) return 0
|
|
151
|
+
await sleep(250)
|
|
152
|
+
}
|
|
153
|
+
say(`the host the sign-in task started did not answer within ${Math.round(signInMs / 1000)} s; starting one through WMI instead`)
|
|
154
|
+
} else if (s.reason === 'nosession') {
|
|
155
|
+
say('nobody is signed in to Windows here: starting the host through WMI, outside any sign-in, where Git cannot use the credentials saved in Windows')
|
|
156
|
+
} else if (s.reason === 'error') {
|
|
157
|
+
say(`could not start the host in the Windows sign-in (${s.message}); starting it through WMI`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
134
161
|
const log = join(dataDir, SERVE_LOG_FILE)
|
|
135
162
|
// One start per log: the last one's is kept beside it, and its absence is how a start that never ran shows
|
|
136
163
|
try {
|
|
@@ -138,7 +165,10 @@ export async function runDetach({
|
|
|
138
165
|
} catch {
|
|
139
166
|
// there was none
|
|
140
167
|
}
|
|
141
|
-
|
|
168
|
+
// Where this was asked for from (#1223): the launcher runs outside this ssh session, so it is told
|
|
169
|
+
const asked = serveFrom(env, from)
|
|
170
|
+
const childEnv = asked ? { ...env, CENTRALU_STARTED_FROM: JSON.stringify(asked) } : env
|
|
171
|
+
const args = [cliPath, 'serve', ...(port !== null ? ['--port', String(port)] : []), `${CHILD_FLAG}${encodeChildSpec({ env: childEnv, log })}`]
|
|
142
172
|
let started
|
|
143
173
|
try {
|
|
144
174
|
started = await start({ how, execPath, args, env, cwd })
|
|
@@ -196,18 +226,8 @@ export async function runDetachedChild({ spec, ...opts }) {
|
|
|
196
226
|
}
|
|
197
227
|
|
|
198
228
|
/**
|
|
199
|
-
* `centralu serve --stop` (docs/plans/remote-hub.md §10.4, S10):
|
|
200
|
-
*
|
|
201
|
-
* processes stopped in order, the store closed). Measured on Windows: killing the WMI-started
|
|
202
|
-
* launcher ended the host 143 ms later with no "shutting down" line, because the host sits in
|
|
203
|
-
* libuv's kill-on-close job object; so on Windows killing is the last resort, not the way.
|
|
204
|
-
*
|
|
205
|
-
* Then, only for a host that did not go: posix SIGTERM to the launcher (which passes it to the host,
|
|
206
|
-
* the same clean stop), and on Windows ending the host. A host from before `host.stop` gets that
|
|
207
|
-
* too. The launcher ends with its host; one still there afterwards is ended.
|
|
208
|
-
*
|
|
209
|
-
* Only the processes serve.pid and host.lock name, and only while they agree with each other and
|
|
210
|
-
* the host answered with this folder's token: a stale pid is never signalled.
|
|
229
|
+
* `centralu serve --stop` (docs/plans/remote-hub.md §10.4, S10): the running host stopped in order, over its own
|
|
230
|
+
* socket, and waited for (`stopServedHost` in serve-handover.mjs, which the hand-over into the sign-in shares, #1224).
|
|
211
231
|
*
|
|
212
232
|
* stdout: one line, `{ v: 1, stop: { ok, wasRunning, how?, message? } }`.
|
|
213
233
|
*/
|
|
@@ -222,56 +242,5 @@ export async function runStop({
|
|
|
222
242
|
write = (line) => process.stdout.write(line),
|
|
223
243
|
}) {
|
|
224
244
|
const dataDir = serveDataDir(env, home)
|
|
225
|
-
|
|
226
|
-
const state = readServeState(dataDir)
|
|
227
|
-
if (!state) return report({ ok: true, wasRunning: false, message: `no centralu serve has run for ${dataDir}` })
|
|
228
|
-
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
229
|
-
const installed = protocolVersionOf(entry) ?? 0
|
|
230
|
-
const prove = hostProvesOf(entry)
|
|
231
|
-
const probe = () => probeHost({ port, token: state.token, protocolVersion: installed, prove })
|
|
232
|
-
const first = await probe()
|
|
233
|
-
if (!heldHere(first, dataDir)) return report({ ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}` })
|
|
234
|
-
|
|
235
|
-
const hostPid = lockHolderPid(dataDir)
|
|
236
|
-
const launcher = readServePid(dataDir)
|
|
237
|
-
// serve.pid is only believed when it names the host that holds the folder now
|
|
238
|
-
const launcherPid = launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
|
|
239
|
-
const down = async () => !(await probe()).running && !(hostPid && isAlive(hostPid))
|
|
240
|
-
|
|
241
|
-
let asked = await askHostToStop({ port, token: state.token, protocolVersion: first.protocolVersion ?? installed, prove })
|
|
242
|
-
if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, token: state.token, protocolVersion: asked.protocolVersion, prove })
|
|
243
|
-
let how = 'asked'
|
|
244
|
-
let stopped = asked.ok && (await waitUntil(down, timeoutMs))
|
|
245
|
-
let note = asked.ok ? null : `the host did not take the request to stop (${asked.message})`
|
|
246
|
-
if (!stopped) {
|
|
247
|
-
const target = platform !== 'win32' && launcherPid ? launcherPid : hostPid
|
|
248
|
-
if (target) {
|
|
249
|
-
how = platform === 'win32' ? 'ended' : 'signal'
|
|
250
|
-
try {
|
|
251
|
-
kill(target, 'SIGTERM')
|
|
252
|
-
} catch {
|
|
253
|
-
// gone in the meantime
|
|
254
|
-
}
|
|
255
|
-
stopped = await waitUntil(down, platform === 'win32' ? 5_000 : graceMs)
|
|
256
|
-
if (!stopped && hostPid && isAlive(hostPid)) {
|
|
257
|
-
how = 'ended'
|
|
258
|
-
try {
|
|
259
|
-
kill(hostPid, 'SIGKILL')
|
|
260
|
-
} catch {
|
|
261
|
-
// gone in the meantime
|
|
262
|
-
}
|
|
263
|
-
stopped = await waitUntil(down, 5_000)
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
if (how === 'ended') note = `${note ? `${note}; ` : ''}ended the host without its shutdown`
|
|
267
|
-
}
|
|
268
|
-
if (launcherPid && launcherPid !== process.pid && !(await waitUntil(() => !isAlive(launcherPid), 5_000))) {
|
|
269
|
-
try {
|
|
270
|
-
kill(launcherPid, 'SIGKILL')
|
|
271
|
-
} catch {
|
|
272
|
-
// gone in the meantime
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
if (!stopped) return report({ ok: false, wasRunning: true, how, message: `the host on 127.0.0.1:${port} is still running${note ? ` (${note})` : ''}` })
|
|
276
|
-
return report({ ok: true, wasRunning: true, how, ...(note ? { message: note } : {}) })
|
|
245
|
+
return answerLine(write, 'stop', await stopServedHost({ dataDir, entry, platform, timeoutMs, graceMs, kill }))
|
|
277
246
|
}
|
package/bin/serve-files.mjs
CHANGED
|
@@ -183,13 +183,18 @@ export function lockHolderIsAlive(dataDir, startOf = processStartTime) {
|
|
|
183
183
|
return startOf(holder.pid) === holder.started
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
/**
|
|
186
|
+
/**
|
|
187
|
+
* host.lock's `{ pid, started, by? }`; a bare pid (an older host) has no start time. `by` (#1223, additive) is who
|
|
188
|
+
* started the host: `app`, `keeper` or `serve` (`instance-lock.ts`), absent when the host did not say
|
|
189
|
+
*/
|
|
187
190
|
function parseLockHolder(text) {
|
|
188
191
|
const t = text.trim()
|
|
189
192
|
if (/^\d+$/.test(t)) return Number(t) > 0 ? { pid: Number(t), started: null } : null
|
|
190
193
|
try {
|
|
191
194
|
const v = JSON.parse(t)
|
|
192
|
-
|
|
195
|
+
if (!Number.isInteger(v?.pid) || v.pid <= 0) return null
|
|
196
|
+
const by = v.by === 'app' || v.by === 'keeper' || v.by === 'serve' ? v.by : null
|
|
197
|
+
return { pid: v.pid, started: typeof v.started === 'string' ? v.started : null, ...(by ? { by } : {}) }
|
|
193
198
|
} catch {
|
|
194
199
|
return null
|
|
195
200
|
}
|
|
@@ -208,6 +213,15 @@ export function processStartTime(pid) {
|
|
|
208
213
|
}
|
|
209
214
|
}
|
|
210
215
|
|
|
216
|
+
/** Who `host.lock` names as the data folder's owner, `{ pid, started, by? }`, or null */
|
|
217
|
+
export function lockHolder(dataDir) {
|
|
218
|
+
try {
|
|
219
|
+
return parseLockHolder(readFileSync(join(dataDir, 'host.lock'), 'utf8'))
|
|
220
|
+
} catch {
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
211
225
|
/** Who `host.lock` names as the data folder's owner, or null */
|
|
212
226
|
export function lockHolderPid(dataDir) {
|
|
213
227
|
try {
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stopping the host `serve` runs for a data folder, and moving one that runs in a network logon into the person's
|
|
3
|
+
* Windows sign-in (#1224). One module so the foreground `serve` (serve-run.mjs) and `--stop` (serve-detach.mjs) share
|
|
4
|
+
* the stop without importing each other.
|
|
5
|
+
*/
|
|
6
|
+
import { readServeState, heldHere, lockHolderPid, readServePid } from './serve-files.mjs'
|
|
7
|
+
import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
|
|
8
|
+
import { protocolVersionOf, hostProvesOf, probeHost, askHostToStop, askHostRpc } from './serve-host.mjs'
|
|
9
|
+
import { ownLogon } from './serve-windows.mjs'
|
|
10
|
+
import { isAlive, say, sleep, waitUntil } from './serve-util.mjs'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Asks the host serve runs for `dataDir` to stop over its own socket and waits until it has, so its shutdown runs (its
|
|
14
|
+
* agents, terminals and app processes stopped in order, the store closed). Measured on Windows: killing the
|
|
15
|
+
* WMI-started launcher ended the host 143 ms later with no "shutting down" line, because the host sits in libuv's
|
|
16
|
+
* kill-on-close job object; so on Windows killing is the last resort, not the way.
|
|
17
|
+
*
|
|
18
|
+
* Then, only for a host that did not go: posix SIGTERM to the launcher (which passes it to the host, the same clean
|
|
19
|
+
* stop), and on Windows ending the host. A host from before `host.stop` gets that too. The launcher ends with its host;
|
|
20
|
+
* one still there afterwards is ended.
|
|
21
|
+
*
|
|
22
|
+
* Only the processes serve.pid and host.lock name, and only while they agree with each other and the host answered
|
|
23
|
+
* with this folder's token: a stale pid is never signalled.
|
|
24
|
+
*
|
|
25
|
+
* `{ ok, wasRunning, how?, message? }`, the `--stop` line's `stop`. With `ifIdle` (#1224, the hand-over) the host stops only
|
|
26
|
+
* when nothing runs there, and `{ ok: false, busy: true }` says it did not.
|
|
27
|
+
*/
|
|
28
|
+
export async function stopServedHost({ dataDir, entry, platform = process.platform, ifIdle = false, timeoutMs = 30_000, graceMs = 15_000, kill = (pid, sig) => process.kill(pid, sig) }) {
|
|
29
|
+
const state = readServeState(dataDir)
|
|
30
|
+
if (!state) return { ok: true, wasRunning: false, message: `no centralu serve has run for ${dataDir}` }
|
|
31
|
+
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
32
|
+
const installed = protocolVersionOf(entry) ?? 0
|
|
33
|
+
const prove = hostProvesOf(entry)
|
|
34
|
+
const probe = () => probeHost({ port, token: state.token, protocolVersion: installed, prove })
|
|
35
|
+
const first = await probe()
|
|
36
|
+
if (!heldHere(first, dataDir)) return { ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}` }
|
|
37
|
+
|
|
38
|
+
const hostPid = lockHolderPid(dataDir)
|
|
39
|
+
const launcher = readServePid(dataDir)
|
|
40
|
+
// serve.pid is only believed when it names the host that holds the folder now
|
|
41
|
+
const launcherPid = launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
|
|
42
|
+
const down = async () => !(await probe()).running && !(hostPid && isAlive(hostPid))
|
|
43
|
+
|
|
44
|
+
let asked = await askHostToStop({ port, token: state.token, protocolVersion: first.protocolVersion ?? installed, prove, ifIdle })
|
|
45
|
+
if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, token: state.token, protocolVersion: asked.protocolVersion, prove, ifIdle })
|
|
46
|
+
// `ifIdle` and it has work running (#1224): it was left as it is
|
|
47
|
+
if (asked.busy) return { ok: false, wasRunning: true, busy: true, message: asked.message }
|
|
48
|
+
let how = 'asked'
|
|
49
|
+
let stopped = asked.ok && (await waitUntil(down, timeoutMs))
|
|
50
|
+
let note = asked.ok ? null : `the host did not take the request to stop (${asked.message})`
|
|
51
|
+
if (!stopped) {
|
|
52
|
+
const target = platform !== 'win32' && launcherPid ? launcherPid : hostPid
|
|
53
|
+
if (target) {
|
|
54
|
+
how = platform === 'win32' ? 'ended' : 'signal'
|
|
55
|
+
try {
|
|
56
|
+
kill(target, 'SIGTERM')
|
|
57
|
+
} catch {
|
|
58
|
+
// gone in the meantime
|
|
59
|
+
}
|
|
60
|
+
stopped = await waitUntil(down, platform === 'win32' ? 5_000 : graceMs)
|
|
61
|
+
if (!stopped && hostPid && isAlive(hostPid)) {
|
|
62
|
+
how = 'ended'
|
|
63
|
+
try {
|
|
64
|
+
kill(hostPid, 'SIGKILL')
|
|
65
|
+
} catch {
|
|
66
|
+
// gone in the meantime
|
|
67
|
+
}
|
|
68
|
+
stopped = await waitUntil(down, 5_000)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (how === 'ended') note = `${note ? `${note}; ` : ''}ended the host without its shutdown`
|
|
72
|
+
}
|
|
73
|
+
if (launcherPid && launcherPid !== process.pid && !(await waitUntil(() => !isAlive(launcherPid), 5_000))) {
|
|
74
|
+
try {
|
|
75
|
+
kill(launcherPid, 'SIGKILL')
|
|
76
|
+
} catch {
|
|
77
|
+
// gone in the meantime
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!stopped) return { ok: false, wasRunning: true, how, message: `the host on 127.0.0.1:${port} is still running${note ? ` (${note})` : ''}` }
|
|
81
|
+
return { ok: true, wasRunning: true, how, ...(note ? { message: note } : {}) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** How long the hand-over waits for the host's work to end before it gives up and says so (#1224 review) */
|
|
85
|
+
export const HANDOVER_WAIT_MS = 30 * 60_000
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The hand-over (#1224): a `serve` started in the person's Windows sign-in (the scheduled task, at sign-in or from
|
|
89
|
+
* `--detach`) found the folder held by a host that runs in a network logon, the one a hub's ssh started before anyone
|
|
90
|
+
* signed in. Git there cannot use the credentials saved in Windows, so the sign-in's host takes over, but only while that
|
|
91
|
+
* host has nothing running a person would lose. Windows has no keeper, so stopping a host ends what runs in it.
|
|
92
|
+
*
|
|
93
|
+
* The stop is `host.stop {ifIdle}`: the host itself checks and stops in one step, and from its yes on takes no new call,
|
|
94
|
+
* so a turn the hub starts cannot slip in between a check here and the stop (`lifecycle/stop-gate.ts` in agent-host).
|
|
95
|
+
* Until it says yes this asks again every `pollMs`, for `maxWaitMs` at most (30 minutes), and tells the host what it is
|
|
96
|
+
* doing (`host.handover`: `waiting`, then `gave_up`), which a hub linked to it shows on the machine's row. It ends early
|
|
97
|
+
* when that host goes by itself.
|
|
98
|
+
*
|
|
99
|
+
* Resolves true once the folder is free for this serve to start its host, false when there is nothing to move (not
|
|
100
|
+
* Windows, a host in any other logon or too old to say, this serve not in an interactive logon itself) or it gave up.
|
|
101
|
+
* The hub's link loses the machine between the stop and the new host's start, and reconnects by its usual backoff to the
|
|
102
|
+
* same port and token.
|
|
103
|
+
*
|
|
104
|
+
* `existing` is `probeHost`'s answer for the running host. `logonOf`, `stop`, `tell`, `pollMs`, `maxWaitMs` and `now` are
|
|
105
|
+
* parameters for tests.
|
|
106
|
+
*/
|
|
107
|
+
export async function moveIntoSignIn({
|
|
108
|
+
dataDir,
|
|
109
|
+
entry,
|
|
110
|
+
env,
|
|
111
|
+
existing,
|
|
112
|
+
platform = process.platform,
|
|
113
|
+
logonOf = () => ownLogon(env),
|
|
114
|
+
stop = () => stopServedHost({ dataDir, entry, platform, ifIdle: true }),
|
|
115
|
+
tell = null,
|
|
116
|
+
pollMs = 30_000,
|
|
117
|
+
maxWaitMs = HANDOVER_WAIT_MS,
|
|
118
|
+
now = Date.now,
|
|
119
|
+
}) {
|
|
120
|
+
if (platform !== 'win32' || existing?.logon !== 'network') return false
|
|
121
|
+
if ((await logonOf()) !== 'interactive') return false
|
|
122
|
+
const state = readServeState(dataDir)
|
|
123
|
+
if (!state) return false
|
|
124
|
+
const port = state.port ?? DEFAULT_SERVE_PORT
|
|
125
|
+
const prove = hostProvesOf(entry)
|
|
126
|
+
const protocolVersion = existing.protocolVersion ?? protocolVersionOf(entry) ?? 0
|
|
127
|
+
const report = (handover) => (tell ?? ((h) => askHostRpc({ port, token: state.token, protocolVersion, prove, method: 'host.handover', params: { handover: h } })))(handover)
|
|
128
|
+
const pid = lockHolderPid(dataDir)
|
|
129
|
+
say(`the host running for ${dataDir}${pid ? ` (pid ${pid})` : ''} runs in a network logon (started over ssh), where Git cannot use the credentials saved in Windows; moving it into this Windows sign-in once nothing runs there`)
|
|
130
|
+
const t0 = now()
|
|
131
|
+
let waiting = false
|
|
132
|
+
for (;;) {
|
|
133
|
+
if (!(await probeHost({ port, token: state.token, protocolVersion, prove })).running) return true
|
|
134
|
+
const r = await stop()
|
|
135
|
+
if (r.ok) {
|
|
136
|
+
say(`stopped it${r.how && r.how !== 'asked' ? ` (${r.how})` : ''}; starting the host in this sign-in`)
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
if (!r.busy) say(`could not stop it: ${r.message ?? 'no reason given'}; asking again later`)
|
|
140
|
+
if (!waiting) {
|
|
141
|
+
say('it has work running (a turn, an approval, a terminal or a command); waiting until it has none')
|
|
142
|
+
await report({ state: 'waiting', since: t0 })
|
|
143
|
+
waiting = true
|
|
144
|
+
}
|
|
145
|
+
if (now() - t0 >= maxWaitMs) {
|
|
146
|
+
await report({ state: 'gave_up', since: now() })
|
|
147
|
+
say(`gave up after ${Math.round(maxWaitMs / 60_000)} minutes: it still had work running. It moves into the sign-in at the next sign-in, or when a linked computer starts it again`)
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
await sleep(pollMs)
|
|
151
|
+
}
|
|
152
|
+
}
|
package/bin/serve-host.mjs
CHANGED
|
@@ -35,6 +35,12 @@ export function protocolVersionOf(entry) {
|
|
|
35
35
|
* is added by `--detach` only.
|
|
36
36
|
*/
|
|
37
37
|
export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, autostart, detach, proof }) {
|
|
38
|
+
/*
|
|
39
|
+
* The options this serve takes that an older one refuses (#1223): a hub adds `--from <its name>` to `--detach` only
|
|
40
|
+
* when the line says so, since an older serve answers an option it does not know with an error. `window`: the
|
|
41
|
+
* desktop window on Windows uses this serve's host through `--window` only when it is listed (#1223 review)
|
|
42
|
+
*/
|
|
43
|
+
const takes = ['from', 'window']
|
|
38
44
|
return JSON.stringify({
|
|
39
45
|
v: 1,
|
|
40
46
|
port,
|
|
@@ -48,9 +54,45 @@ export function connectionLine({ port, token, version, protocolVersion, dataDir,
|
|
|
48
54
|
...(detach ? { detach } : {}),
|
|
49
55
|
// The running host proves it holds the token before the hub sends it (#838); absent: the hub sends hello as before
|
|
50
56
|
...(proof ? { proof: true } : {}),
|
|
57
|
+
takes,
|
|
51
58
|
})
|
|
52
59
|
}
|
|
53
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Where a `serve` was asked for from (#1223), for the host's `host.lock` (`CC_SERVE_FROM`): the hub's name `--from`
|
|
63
|
+
* gave, and the ssh client's address when this runs in an ssh session (OpenSSH's `SSH_CLIENT`, on Windows too). What a
|
|
64
|
+
* `--detach` found is carried to the launcher it starts (`CENTRALU_STARTED_FROM`), which runs outside the ssh session.
|
|
65
|
+
* Null when neither is known.
|
|
66
|
+
*/
|
|
67
|
+
export function serveFrom(env, hub = null) {
|
|
68
|
+
const carried = parseStartedFrom(env.CENTRALU_STARTED_FROM)
|
|
69
|
+
if (carried) return carried
|
|
70
|
+
const ssh = String(env.SSH_CLIENT ?? env.SSH_CONNECTION ?? '').trim().split(/\s+/)[0] ?? ''
|
|
71
|
+
const from = {
|
|
72
|
+
...(typeof hub === 'string' && FROM_NAME_RE.test(hub) ? { hub } : {}),
|
|
73
|
+
...(SSH_ADDRESS_RE.test(ssh) ? { ssh } : {}),
|
|
74
|
+
}
|
|
75
|
+
return from.hub || from.ssh ? from : null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const FROM_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/
|
|
79
|
+
/** An IPv4 or IPv6 address as OpenSSH writes it: digits, hex, dots and colons */
|
|
80
|
+
const SSH_ADDRESS_RE = /^[0-9A-Fa-f.:]{2,64}$/
|
|
81
|
+
|
|
82
|
+
function parseStartedFrom(raw) {
|
|
83
|
+
if (!raw) return null
|
|
84
|
+
try {
|
|
85
|
+
const v = JSON.parse(raw)
|
|
86
|
+
const from = {
|
|
87
|
+
...(typeof v?.hub === 'string' && FROM_NAME_RE.test(v.hub) ? { hub: v.hub } : {}),
|
|
88
|
+
...(typeof v?.ssh === 'string' && SSH_ADDRESS_RE.test(v.ssh) ? { ssh: v.ssh } : {}),
|
|
89
|
+
}
|
|
90
|
+
return from.hub || from.ssh ? from : null
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
54
96
|
/**
|
|
55
97
|
* The host's environment.
|
|
56
98
|
*
|
|
@@ -69,9 +111,11 @@ export function connectionLine({ port, token, version, protocolVersion, dataDir,
|
|
|
69
111
|
* `CENTRALU_MANAGED`, which is the launcher's business and would otherwise reach every agent.
|
|
70
112
|
* - `CC_SERVE=1` lets the host take `host.stop` (`--stop`); a host the app runs refuses it.
|
|
71
113
|
*/
|
|
72
|
-
export function hostEnv(env, { dataDir, version, hostDir, legacyToken }) {
|
|
114
|
+
export function hostEnv(env, { dataDir, version, hostDir, legacyToken, from = null }) {
|
|
73
115
|
const out = { ...env }
|
|
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]
|
|
116
|
+
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', 'CENTRALU_STARTED_FROM', 'CC_SERVE_FROM', 'CC_STARTED_BY']) delete out[k]
|
|
117
|
+
// Who asked for this host, for its host.lock (#1223): a second host on the folder names it when refused
|
|
118
|
+
if (from) out.CC_SERVE_FROM = JSON.stringify(from)
|
|
75
119
|
out.CC_DATA_DIR = dataDir
|
|
76
120
|
// The host `host.stop` may end (`centralu serve --stop`): nothing else stops it in order on Windows
|
|
77
121
|
out.CC_SERVE = '1'
|
|
@@ -238,6 +282,8 @@ export function probeHost({ port, token, protocolVersion, prove = false, timeout
|
|
|
238
282
|
running: true,
|
|
239
283
|
protocolVersion: Number.isInteger(m.protocolVersion) ? m.protocolVersion : undefined,
|
|
240
284
|
version: typeof m.build?.version === 'string' ? m.build.version : undefined,
|
|
285
|
+
// Which Windows logon it runs in (#1224): a `network` one is moved into the sign-in (serve-handover.mjs)
|
|
286
|
+
...(HOST_LOGONS.includes(m.logon) ? { logon: m.logon } : {}),
|
|
241
287
|
})
|
|
242
288
|
} else if (m.kind === 'res' && m.ok === false && m.error?.code === 'version_mismatch') {
|
|
243
289
|
// The token was right (it is checked first), so this is our host, on another protocol
|
|
@@ -289,12 +335,39 @@ function tcpProbe(port, timeoutMs) {
|
|
|
289
335
|
})
|
|
290
336
|
}
|
|
291
337
|
|
|
338
|
+
/** The logons a host's `hello_ok.logon` may name (#1224) */
|
|
339
|
+
const HOST_LOGONS = ['interactive', 'network', 'batch', 'service']
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* One call to a running host (`method` with `params`), as `askHostToStop` makes it: a hello with the token, then the
|
|
343
|
+
* call. Its result, or null when it did not answer or refused. For `host.handover` (#1224)
|
|
344
|
+
*/
|
|
345
|
+
export function askHostRpc({ port, token, protocolVersion, prove = false, method, params = {}, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
|
|
346
|
+
if (typeof WebSocketImpl !== 'function') return Promise.resolve(null)
|
|
347
|
+
return helloExchange({
|
|
348
|
+
port, token, protocolVersion, prove, WebSocketImpl, timeoutMs,
|
|
349
|
+
timedOut: null,
|
|
350
|
+
failed: () => null,
|
|
351
|
+
on: (done) => ({
|
|
352
|
+
unproven: () => done(null),
|
|
353
|
+
gone: () => done(null),
|
|
354
|
+
message: (m, ws) => {
|
|
355
|
+
if (m.kind === 'hello_ok') ws.send(JSON.stringify({ kind: 'rpc', id: 'call', method, params }))
|
|
356
|
+
else if (m.kind === 'res' && m.id === 'call') done(m.ok && m.result && typeof m.result === 'object' ? m.result : null)
|
|
357
|
+
else if (m.kind === 'res' && m.ok === false) done(null)
|
|
358
|
+
},
|
|
359
|
+
}),
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
292
363
|
/**
|
|
293
364
|
* Asks a running host to stop (`host.stop`, an additive RPC): a hello with the token, then the call.
|
|
294
365
|
* `{ ok: true }` once the host took it, `{ ok: false, message, protocolVersion? }` otherwise; the
|
|
295
366
|
* last names the host's protocol when the hello was refused for it, so the caller can ask again.
|
|
367
|
+
* `ifIdle` (#1224): only if nothing runs there, checked and acted on by the host in one step; `{ ok: false, busy: true }`
|
|
368
|
+
* when something does.
|
|
296
369
|
*/
|
|
297
|
-
export function askHostToStop({ port, token, protocolVersion, prove = false, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
|
|
370
|
+
export function askHostToStop({ port, token, protocolVersion, prove = false, ifIdle = false, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
|
|
298
371
|
if (typeof WebSocketImpl !== 'function') return Promise.resolve({ ok: false, message: `Node ${process.version} has no WebSocket to ask with` })
|
|
299
372
|
// With `prove`, the token goes out only to a host that proved it holds it (#838)
|
|
300
373
|
return helloExchange({
|
|
@@ -305,8 +378,11 @@ export function askHostToStop({ port, token, protocolVersion, prove = false, tim
|
|
|
305
378
|
unproven: () => done({ ok: false, message: 'what answers on the port did not prove it holds the token' }),
|
|
306
379
|
gone: () => done({ ok: false, message: 'the connection closed' }),
|
|
307
380
|
message: (m, ws) => {
|
|
308
|
-
if (m.kind === 'hello_ok') ws.send(JSON.stringify({ kind: 'rpc', id: 'stop', method: 'host.stop', params: {} }))
|
|
309
|
-
else if (m.kind === 'res' && m.id === 'stop'
|
|
381
|
+
if (m.kind === 'hello_ok') ws.send(JSON.stringify({ kind: 'rpc', id: 'stop', method: 'host.stop', params: ifIdle ? { ifIdle: true } : {} }))
|
|
382
|
+
else if (m.kind === 'res' && m.id === 'stop' && m.ok && m.result?.ok === false) {
|
|
383
|
+
// `ifIdle` and something runs there (#1224): nothing was changed
|
|
384
|
+
done({ ok: false, busy: true, message: 'it has work running', activity: m.result.busy ?? null, calls: m.result.calls ?? 0 })
|
|
385
|
+
} else if (m.kind === 'res' && m.id === 'stop') done(m.ok ? { ok: true } : { ok: false, message: m.error?.message ?? 'refused' })
|
|
310
386
|
else if (m.kind === 'res' && m.ok === false) {
|
|
311
387
|
const pv = m.error?.code === 'version_mismatch' ? mismatchProtocolOf(m.error) : null
|
|
312
388
|
done({ ok: false, message: m.error?.message ?? 'refused', ...(pv !== null ? { protocolVersion: pv } : {}) })
|
package/bin/serve-run.mjs
CHANGED
|
@@ -4,11 +4,12 @@ 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, lockHolderPid, removeServePid } from './serve-files.mjs'
|
|
7
|
+
import { SERVE_STATE_FILE, SERVE_PID_FILE, 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
|
-
import { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, hostProvesOf, probeHost, takesTokenOnStdin, tokenLine } from './serve-host.mjs'
|
|
10
|
+
import { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, hostProvesOf, probeHost, serveFrom, takesTokenOnStdin, tokenLine } from './serve-host.mjs'
|
|
11
11
|
import { autostartInfo } from './serve-autostart.mjs'
|
|
12
|
+
import { moveIntoSignIn } from './serve-handover.mjs'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The data folder a command that may start or describe a host works in: its state, created on first use, and the
|
|
@@ -70,7 +71,7 @@ export async function printConnection({ env, home, entry, version, cliPath }) {
|
|
|
70
71
|
* from a newer build) says why in a file `--detach` can quote. From then on host.log has every line,
|
|
71
72
|
* and serve.log stops growing.
|
|
72
73
|
*/
|
|
73
|
-
export async function runServe({ env, home, entry, version, cliPath, port: askedPort, platform = process.platform, execPath = process.execPath, logFd = null }) {
|
|
74
|
+
export async function runServe({ env, home, entry, version, cliPath, port: askedPort, from = null, platform = process.platform, execPath = process.execPath, logFd = null, handOver = moveIntoSignIn }) {
|
|
74
75
|
const { dataDir, state } = serveFolder({ env, home, cliPath, platform })
|
|
75
76
|
const protocolVersion = protocolVersionOf(entry)
|
|
76
77
|
|
|
@@ -78,7 +79,8 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
78
79
|
// would only fail on the data folder's lock with a less specific message.
|
|
79
80
|
const recorded = state.port ?? DEFAULT_SERVE_PORT
|
|
80
81
|
const existing = await probeHost({ port: recorded, token: state.token, protocolVersion, prove: hostProvesOf(entry) })
|
|
81
|
-
|
|
82
|
+
// A host the hub's ssh started before anyone signed in moves into this sign-in, once idle (#1224, serve-handover.mjs)
|
|
83
|
+
if (heldHere(existing, dataDir) && !(await handOver({ dataDir, entry, env, existing, platform }))) {
|
|
82
84
|
const pid = lockHolderPid(dataDir)
|
|
83
85
|
say(`centralu serve is already running for ${dataDir} on 127.0.0.1:${recorded}${pid ? ` (pid ${pid})` : ''}. Not starting a second one.`)
|
|
84
86
|
return 1
|
|
@@ -100,7 +102,7 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
100
102
|
const onStdin = takesTokenOnStdin(entry)
|
|
101
103
|
const child = spawn(plan.command, plan.args, {
|
|
102
104
|
stdio: ['pipe', 'pipe', logFd === null ? 'inherit' : 'pipe'],
|
|
103
|
-
env: hostEnv(env, { dataDir, version, hostDir: dirname(entry), ...(onStdin ? {} : { legacyToken: state.token }) }),
|
|
105
|
+
env: hostEnv(env, { dataDir, version, hostDir: dirname(entry), from: serveFrom(env, from), ...(onStdin ? {} : { legacyToken: state.token }) }),
|
|
104
106
|
detached: plan.detached,
|
|
105
107
|
windowsHide: true,
|
|
106
108
|
})
|
|
@@ -168,12 +170,11 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
168
170
|
for (const s of signals) process.off(s, forward)
|
|
169
171
|
removeServePid(dataDir, process.pid)
|
|
170
172
|
if (!ready && code !== 0) {
|
|
171
|
-
const
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
)
|
|
173
|
+
const holder = lockHolder(dataDir)
|
|
174
|
+
if (holder && holder.pid !== child.pid && isAlive(holder.pid)) {
|
|
175
|
+
// Who it is, when its host.lock says (#1223)
|
|
176
|
+
const who = { app: 'the Centralu window on this machine', keeper: "Centralu's keeper on this machine", serve: 'another centralu serve' }[holder.by] ?? 'probably the Centralu app on this machine'
|
|
177
|
+
say(`the host did not start. ${dataDir} belongs to another Centralu host (pid ${holder.pid}), ${who}. Quit it, or point CC_DATA_DIR at another folder.`)
|
|
177
178
|
}
|
|
178
179
|
}
|
|
179
180
|
if (logFd !== null) say(`the host exited (${signal ?? code})`)
|
|
@@ -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
|
+
}
|
package/bin/serve-windows.mjs
CHANGED
|
@@ -31,6 +31,21 @@ export function wslInterop() {
|
|
|
31
31
|
return existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') || existsSync('/proc/sys/fs/binfmt_misc/WSLInterop-late')
|
|
32
32
|
}
|
|
33
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
|
+
|
|
34
49
|
/** A WSL distro's name for `wsl.exe -d`, refused when it could be read as anything else */
|
|
35
50
|
export function wslDistro(distro) {
|
|
36
51
|
if (!/^[A-Za-z0-9._-]{1,64}$/.test(distro ?? '')) throw new Error(`not a WSL distro name: ${distro}`)
|
package/bin/serve.mjs
CHANGED
|
@@ -24,12 +24,14 @@
|
|
|
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, lockHolderPid, readServePid } from './serve-files.mjs'
|
|
28
|
-
export { detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, windowsPowershell } from './serve-windows.mjs'
|
|
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'
|
|
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'
|
|
31
31
|
export { launcherScript, ensureLauncher, remoteLayout, parsePointer, installInfo, managedLauncherScript } from './serve-install.mjs'
|
|
32
|
-
export { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, mismatchServerVersion, hostProvesOf, probeHost, askHostToStop, takesTokenOnStdin, tokenLine } from './serve-host.mjs'
|
|
33
|
-
export {
|
|
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'
|
|
34
36
|
export { printConnection, runServe, rotateToken } from './serve-run.mjs'
|
|
35
37
|
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.30",
|
|
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.30",
|
|
42
|
+
"@centralu/linux-arm64": "0.1.0-beta.30",
|
|
43
|
+
"@centralu/linux-x64": "0.1.0-beta.30",
|
|
44
|
+
"@centralu/win32-x64": "0.1.0-beta.30"
|
|
45
45
|
}
|
|
46
46
|
}
|