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 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}`)
@@ -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 --autostart on|off|status
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
- or say whether it does. Turning it on or off leaves a host that
16
- runs now as it is
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) {
@@ -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
- return `Centralu host${distro ? ` (WSL ${distro})` : ''}${own ? ` ${own}` : ''}`
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
- * line. Registered for this user only, at this user's sign-in, run only while they are signed in
159
- * (`Interactive`): the kind of task a user may create without an administrator. On a laptop, it runs
160
- * on battery too, and with no time limit (the default ends a task after 72 hours).
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
- if (action === 'status') return [...head, 'try {', ` ${find}`, ` if ($t) { "${TASK_WORD} state $($t.State)" } else { '${TASK_WORD} state none' }`, fail].join('\n')
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
- return [...head, 'try {', ` ${find}`, " if ($t) { Unregister-ScheduledTask -TaskName $name -TaskPath '\\' -Confirm:$false }", ` '${TASK_WORD} off'`, fail].join('\n')
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
- return [
172
- ...head,
173
- 'try {',
174
- " $exe = Join-Path $env:SystemRoot 'System32\\WindowsPowerShell\\v1.0\\powershell.exe'",
175
- ` $arg = '-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encoded}'`,
176
- ' $user = if ($env:USERDOMAIN) { "$env:USERDOMAIN\\$env:USERNAME" } else { $env:USERNAME }',
177
- ' $a = New-ScheduledTaskAction -Execute $exe -Argument $arg',
178
- ' $tr = New-ScheduledTaskTrigger -AtLogOn -User $user',
179
- ' $p = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited',
180
- ' $s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew',
181
- ` Register-ScheduledTask -TaskName $name -TaskPath '\\' -Action $a -Trigger $tr -Principal $p -Settings $s -Description ${psText('Starts the Centralu host (centralu serve) when you sign in. Written by centralu serve --autostart on.')} -Force | Out-Null`,
182
- ` '${TASK_WORD} on'`,
183
- fail,
184
- ].join('\n')
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
- const on = a.word !== 'none' && a.word !== 'Disabled'
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
- const a = await ask(taskScript('off', { name }))
308
- if (!a.ok) return { ...base, ok: false, on: true, message: `Task Scheduler did not remove the task "${name}": ${a.message}` }
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
- * `centralu serve --autostart on|off|status` (docs/plans/remote-hub.md §10.4, owner decision 4: per
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`. Turning it on or off never starts or stops a host: one runs already when the
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) {
@@ -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, waitUntil } from './serve-util.mjs'
10
- import { SERVE_LOG_FILE, serveDataDir, readServeState, ensureServeState, heldHere, lockHolderPid, readServePid } from './serve-files.mjs'
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 { protocolVersionOf, connectionLine, hostProvesOf, probeHost, askHostToStop } from './serve-host.mjs'
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
- const args = [cliPath, 'serve', ...(port !== null ? ['--port', String(port)] : []), `${CHILD_FLAG}${encodeChildSpec({ env, log })}`]
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): asks the running host to stop over
200
- * its own socket and waits until it has, so its shutdown runs (its agents, terminals and app
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
- const report = (stop) => answerLine(write, 'stop', stop)
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
  }
@@ -183,13 +183,18 @@ export function lockHolderIsAlive(dataDir, startOf = processStartTime) {
183
183
  return startOf(holder.pid) === holder.started
184
184
  }
185
185
 
186
- /** host.lock's `{ pid, started }`; a bare pid (an older host) has no start time */
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
- return Number.isInteger(v?.pid) && v.pid > 0 ? { pid: v.pid, started: typeof v.started === 'string' ? v.started : null } : null
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
+ }
@@ -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') done(m.ok ? { ok: true } : { ok: false, message: m.error?.message ?? 'refused' })
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
- if (heldHere(existing, dataDir)) {
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 pid = lockHolderPid(dataDir)
172
- if (pid && pid !== child.pid && isAlive(pid)) {
173
- say(
174
- `the host did not start. ${dataDir} belongs to another Centralu host (pid ${pid}), ` +
175
- 'probably the Centralu app on this machine. Quit it, or point CC_DATA_DIR at another folder.',
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
+ }
@@ -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 { AUTOSTART_FILE, AUTOSTART_ENV, autostartHow, autostartUnsupported, pathKey, autostartName, autostartLauncher, systemdUnit, taskActionScript, taskScript, parseTaskAnswer, readAutostartMark, autostartInfo, lingerMessage, runAutostart } from './serve-autostart.mjs'
32
+ export { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, mismatchServerVersion, hostProvesOf, probeHost, askHostToStop, askHostRpc, serveFrom, takesTokenOnStdin, tokenLine } from './serve-host.mjs'
33
+ export { stopServedHost, moveIntoSignIn, HANDOVER_WAIT_MS } from './serve-handover.mjs'
34
+ export { runWindow } from './serve-window.mjs'
35
+ export { AUTOSTART_FILE, AUTOSTART_ENV, autostartHow, autostartUnsupported, pathKey, autostartName, autostartLauncher, systemdUnit, taskActionScript, taskScript, parseTaskAnswer, readAutostartMark, autostartInfo, lingerMessage, runAutostart, startInSignIn } from './serve-autostart.mjs'
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.29",
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.29",
42
- "@centralu/linux-arm64": "0.1.0-beta.29",
43
- "@centralu/linux-x64": "0.1.0-beta.29",
44
- "@centralu/win32-x64": "0.1.0-beta.29"
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
  }