centralu 0.1.0-beta.26 → 0.1.0-beta.27
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/platform.mjs +2 -1
- package/bin/serve-args.mjs +97 -0
- package/bin/serve-autostart.mjs +358 -0
- package/bin/serve-detach.mjs +277 -0
- package/bin/serve-files.mjs +203 -0
- package/bin/serve-host.mjs +294 -0
- package/bin/serve-install.mjs +186 -0
- package/bin/serve-port.mjs +79 -0
- package/bin/serve-run.mjs +189 -0
- package/bin/serve-util.mjs +94 -0
- package/bin/serve-windows.mjs +156 -0
- package/bin/serve.mjs +18 -1833
- package/package.json +5 -5
package/bin/platform.mjs
CHANGED
|
@@ -200,7 +200,8 @@ export function installedPaths(platform, env, home) {
|
|
|
200
200
|
return [posix.join(home, '.local/share/applications/centralu.desktop')]
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
/** The Windows folder (`%SystemRoot%`), where Windows' own programs are named from */
|
|
204
|
+
export function systemRoot(env) {
|
|
204
205
|
return env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows'
|
|
205
206
|
}
|
|
206
207
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** The arguments after `centralu serve`, and its help */
|
|
2
|
+
import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
|
|
3
|
+
|
|
4
|
+
export const SERVE_HELP = `centralu serve: run the Centralu host on this machine, without a window
|
|
5
|
+
|
|
6
|
+
centralu serve Start the host in the foreground, on 127.0.0.1 only
|
|
7
|
+
centralu serve --port <n> ... on that port (kept for the next start; default ${DEFAULT_SERVE_PORT})
|
|
8
|
+
centralu serve --detach Start it in the background, outside this session; return once it answers
|
|
9
|
+
centralu serve --stop Ask the running host to stop, and wait until it has
|
|
10
|
+
centralu serve --connection Print how to reach it as one JSON line, then exit
|
|
11
|
+
centralu serve --rotate-token Replace the token (a running serve keeps the old one until restarted)
|
|
12
|
+
centralu serve --autostart on|off|status
|
|
13
|
+
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
|
|
17
|
+
|
|
18
|
+
The data folder is ~/.centralu (CC_DATA_DIR overrides it). The app on another computer reaches
|
|
19
|
+
this host through an SSH local forward, and starts it with --detach when it is not running.
|
|
20
|
+
Stop a foreground one with Ctrl+C or SIGTERM, a detached one with --stop. A detached one writes
|
|
21
|
+
how its start went to serve.log in the data folder; the host's own log is host.log.
|
|
22
|
+
|
|
23
|
+
serve, --detach and --connection keep ~/.centralu/bin/centralu up to date: a launcher with absolute
|
|
24
|
+
paths, for SSH sessions whose PATH has neither npm's global folder nor node. Not when they run
|
|
25
|
+
from an install the app made over SSH, which has a launcher of its own.`
|
|
26
|
+
|
|
27
|
+
/** The flags that each make serve a different command */
|
|
28
|
+
const MODE_FLAGS = { '--connection': 'connection', '--rotate-token': 'rotate', '--detach': 'detach', '--stop': 'stop' }
|
|
29
|
+
const FLAG_OF = { ...Object.fromEntries(Object.entries(MODE_FLAGS).map(([f, m]) => [m, f])), autostart: '--autostart' }
|
|
30
|
+
|
|
31
|
+
/** What `--autostart` takes */
|
|
32
|
+
export const AUTOSTART_ACTIONS = ['on', 'off', 'status']
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The argument a detached launcher is started with: what it needs from the `--detach` that started
|
|
36
|
+
* it, which a process created through WMI does not inherit (it gets the account's default
|
|
37
|
+
* environment, not this one). Never a secret: the token stays in serve.json. Internal, not in the help.
|
|
38
|
+
*/
|
|
39
|
+
export const CHILD_FLAG = '--detached-child='
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What the arguments after `serve` ask for: `{ mode, port }` (plus `child` for a detached launcher,
|
|
43
|
+
* `autostart` for `--autostart`) or `{ error }`.
|
|
44
|
+
*
|
|
45
|
+
* `mode` is `serve`, `detach`, `stop`, `connection`, `rotate`, `autostart` or `help`; `port` is null
|
|
46
|
+
* when not given. `--port` goes with `serve` and `--detach` only: `--connection` has to report the
|
|
47
|
+
* port `serve` uses, not one the caller names, `--stop` stops the one that runs, and the entry
|
|
48
|
+
* `--autostart` writes starts serve on the port it recorded.
|
|
49
|
+
*/
|
|
50
|
+
export function parseServeArgs(argv) {
|
|
51
|
+
let mode = 'serve'
|
|
52
|
+
let port = null
|
|
53
|
+
let help = false
|
|
54
|
+
let child = null
|
|
55
|
+
let autostart = null
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
const a = argv[i]
|
|
58
|
+
if (a === '--autostart' || a.startsWith('--autostart=')) {
|
|
59
|
+
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)})` }
|
|
61
|
+
if (mode !== 'serve' && mode !== 'autostart') return { error: `${FLAG_OF[mode]} and --autostart are separate commands; pass one` }
|
|
62
|
+
mode = 'autostart'
|
|
63
|
+
autostart = raw
|
|
64
|
+
} else if (Object.hasOwn(MODE_FLAGS, a)) {
|
|
65
|
+
const m = MODE_FLAGS[a]
|
|
66
|
+
if (mode !== 'serve' && mode !== m) return { error: `${FLAG_OF[mode]} and ${a} are separate commands; pass one` }
|
|
67
|
+
mode = m
|
|
68
|
+
} else if (a === '--help' || a === '-h') help = true
|
|
69
|
+
else if (a.startsWith(CHILD_FLAG)) child = a.slice(CHILD_FLAG.length)
|
|
70
|
+
else if (a === '--port' || a.startsWith('--port=')) {
|
|
71
|
+
const raw = a === '--port' ? argv[++i] : a.slice('--port='.length)
|
|
72
|
+
const n = parsePort(raw)
|
|
73
|
+
if (n === null) return { error: `--port needs a port number between 1 and 65535 (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
|
|
74
|
+
port = n
|
|
75
|
+
} else return { error: `unknown option for serve: ${a}\n\n${SERVE_HELP}` }
|
|
76
|
+
}
|
|
77
|
+
if (help) return { mode: 'help', port }
|
|
78
|
+
if (child !== null) {
|
|
79
|
+
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 }
|
|
81
|
+
}
|
|
82
|
+
if ((mode === 'connection' || mode === 'rotate') && port !== null) {
|
|
83
|
+
return { error: '--port goes with `centralu serve`; `--connection` reports the port serve listens on' }
|
|
84
|
+
}
|
|
85
|
+
if (mode === 'stop' && port !== null) return { error: '--stop stops the serve that is running, on the port it recorded; it takes no --port' }
|
|
86
|
+
if (mode === 'autostart') {
|
|
87
|
+
if (port !== null) return { error: '--autostart takes no --port: the entry it writes starts serve on the port serve recorded' }
|
|
88
|
+
return { mode, port, autostart }
|
|
89
|
+
}
|
|
90
|
+
return { mode, port }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parsePort(raw) {
|
|
94
|
+
if (typeof raw !== 'string' || !/^\d{1,5}$/.test(raw)) return null
|
|
95
|
+
const n = Number(raw)
|
|
96
|
+
return n >= 1 && n <= 65535 ? n : null
|
|
97
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `centralu serve --autostart on|off|status`: a `systemd --user` unit on Linux, a scheduled task at
|
|
3
|
+
* sign-in on Windows and for WSL, and the mark in the data folder that `--connection` reports.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
7
|
+
import { userInfo } from 'node:os'
|
|
8
|
+
import { join, posix } from 'node:path'
|
|
9
|
+
import { answerLine, replaceFile, runCaptured } from './serve-util.mjs'
|
|
10
|
+
import { serveDataDir } from './serve-files.mjs'
|
|
11
|
+
import { askPowershell, detachHow, encodedCommand, wslInterop, PS_MODULE_PATH, windowsArg, windowsPowershell, psText, wslDistro, wslUser } from './serve-windows.mjs'
|
|
12
|
+
import { keepLauncher, remoteLayout } from './serve-install.mjs'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What `--autostart on` records in the data folder: the entry it wrote, so `--connection` can say
|
|
16
|
+
* whether this machine starts its host at boot without asking systemd or Task Scheduler at every
|
|
17
|
+
* link start. `--autostart status` asks them, and corrects this file when it disagrees.
|
|
18
|
+
*/
|
|
19
|
+
export const AUTOSTART_FILE = 'autostart.json'
|
|
20
|
+
|
|
21
|
+
/** The variables the entry carries to the serve it starts: the data folder, and a host entry (development) */
|
|
22
|
+
export const AUTOSTART_ENV = ['CC_DATA_DIR', 'CENTRALU_HOST_ENTRY']
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How this machine would start `serve` at boot (docs/plans/remote-hub.md §10.4, owner decision 4),
|
|
26
|
+
* or null where it offers none:
|
|
27
|
+
*
|
|
28
|
+
* systemd Linux: a `systemd --user` unit, enabled for `default.target`, plus lingering so the
|
|
29
|
+
* user's manager starts at boot rather than at the first login
|
|
30
|
+
* task Windows: a per-user scheduled task at sign-in (no administrator needed). Also inside
|
|
31
|
+
* WSL: nothing in a stopped distro runs, so the Windows side has to start it, through
|
|
32
|
+
* `wsl.exe`, which then holds the distro for as long as the host runs
|
|
33
|
+
*
|
|
34
|
+
* WSL with interop off has no way to the Windows side, and a Linux without systemd (or macOS) has
|
|
35
|
+
* no user unit; those say so instead.
|
|
36
|
+
*/
|
|
37
|
+
export function autostartHow(platform, env, { interop = wslInterop, systemd = () => existsSync('/run/systemd/system') } = {}) {
|
|
38
|
+
if (platform === 'win32') return 'task'
|
|
39
|
+
if (platform !== 'linux') return null
|
|
40
|
+
if (env.WSL_DISTRO_NAME) return interop() ? 'task' : null
|
|
41
|
+
return systemd() ? 'systemd' : null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Why `autostartHow` found nothing, in one sentence */
|
|
45
|
+
export function autostartUnsupported(platform, env) {
|
|
46
|
+
if (platform === 'linux' && env.WSL_DISTRO_NAME) {
|
|
47
|
+
return 'Starting Centralu with WSL needs a Windows scheduled task, and this distro cannot run Windows programs (WSL interop is off)'
|
|
48
|
+
}
|
|
49
|
+
if (platform === 'linux') return 'This Linux does not run systemd, so there is no user unit to start Centralu at boot; start `centralu serve` from your own init system'
|
|
50
|
+
return `Starting Centralu at boot is offered on Linux and Windows; on ${platform === 'darwin' ? 'macOS' : platform}, run \`centralu serve --detach\` from a login item or launchd agent of your own`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A folder path as a comparable key on `platform`: either separator as `/`, `.` and `..` folded, no
|
|
55
|
+
* trailing separator, and lower case on Windows, whose paths do not differ by case
|
|
56
|
+
*/
|
|
57
|
+
export function pathKey(p, platform) {
|
|
58
|
+
let s = posix.normalize(String(p).replaceAll('\\', '/'))
|
|
59
|
+
if (s.length > 1) s = s.replace(/\/+$/, '')
|
|
60
|
+
return platform === 'win32' ? s.toLowerCase() : s
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The entry's name. One per data folder: the default one gets the plain name, any other (a second
|
|
65
|
+
* install, a test's temporary folder) a suffix from its path, so they never replace each other. A
|
|
66
|
+
* Windows laptop linked twice, as Windows and as a WSL distro, gets two tasks.
|
|
67
|
+
*
|
|
68
|
+
* Whether `dataDir` is the default is read the same way whatever OS runs this (`pathKey`), not with
|
|
69
|
+
* this Node's `join`: the folder may be written with either separator (`CC_DATA_DIR=C:/x` on Windows),
|
|
70
|
+
* and Windows paths do not differ by case, so `C:\Users\Me\.centralu` is the default folder too.
|
|
71
|
+
*/
|
|
72
|
+
export function autostartName(how, { dataDir, home, env, platform = process.platform }) {
|
|
73
|
+
const key = pathKey(dataDir, platform)
|
|
74
|
+
const own = key === pathKey(`${home}/.centralu`, platform) ? '' : createHash('sha256').update(key).digest('hex').slice(0, 8)
|
|
75
|
+
if (how === 'systemd') return `centralu-serve${own ? `-${own}` : ''}.service`
|
|
76
|
+
const distro = env.WSL_DISTRO_NAME && wslDistro(env.WSL_DISTRO_NAME)
|
|
77
|
+
return `Centralu host${distro ? ` (WSL ${distro})` : ''}${own ? ` ${own}` : ''}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What the entry runs: the managed launcher when the app installed Centralu here over ssh (its text
|
|
82
|
+
* never changes and it reads `current`, so an update needs no new entry), else the launcher `serve`
|
|
83
|
+
* keeps for an npm install. The order is the hub's lookup (`connectionCommand` in links/tunnel.ts).
|
|
84
|
+
*/
|
|
85
|
+
export function autostartLauncher(dataDir, platform, exists = existsSync) {
|
|
86
|
+
const file = platform === 'win32' ? 'centralu.cmd' : 'centralu'
|
|
87
|
+
for (const p of [join(remoteLayout(dataDir).bin, file), join(dataDir, 'bin', file)]) if (exists(p)) return p
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One value for a unit file, double-quoted; `$` doubled where systemd would expand it (ExecStart) */
|
|
92
|
+
function systemdQuote(value, { dollars = false } = {}) {
|
|
93
|
+
const s = String(value)
|
|
94
|
+
if (/[\r\n\0]/.test(s)) throw new Error(`cannot put ${JSON.stringify(s)} in a systemd unit`)
|
|
95
|
+
let q = s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')
|
|
96
|
+
if (dollars) q = q.replaceAll('$', '$$$$')
|
|
97
|
+
return `"${q}"`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The `systemd --user` unit (docs/agent-host.md §4.7 has the hand-written one this follows).
|
|
102
|
+
*
|
|
103
|
+
* - `serve` in the foreground: systemd is what keeps it outside any session.
|
|
104
|
+
* - `KillMode=mixed`: a stop sends SIGTERM to the launcher only, which passes one to the host, which
|
|
105
|
+
* stops its agents in order; the rest of the group is killed only after `TimeoutStopSec`.
|
|
106
|
+
* - `Restart=on-failure`, but bounded: a host that fails at every start (a port another program
|
|
107
|
+
* holds, a store from a newer build) is left failed after five tries in five minutes rather than
|
|
108
|
+
* started forever. A clean stop (`serve --stop`, as an update does) exits 0 and stays stopped.
|
|
109
|
+
*/
|
|
110
|
+
export function systemdUnit({ launcher, env }) {
|
|
111
|
+
return [
|
|
112
|
+
'# Written by `centralu serve --autostart on`; `centralu serve --autostart off` removes it.',
|
|
113
|
+
'[Unit]',
|
|
114
|
+
'Description=Centralu host (centralu serve)',
|
|
115
|
+
'StartLimitIntervalSec=300',
|
|
116
|
+
'StartLimitBurst=5',
|
|
117
|
+
'',
|
|
118
|
+
'[Service]',
|
|
119
|
+
'Type=simple',
|
|
120
|
+
...AUTOSTART_ENV.filter((k) => env[k]).map((k) => `Environment=${systemdQuote(`${k}=${env[k]}`)}`),
|
|
121
|
+
`ExecStart=${systemdQuote(launcher, { dollars: true })} serve`,
|
|
122
|
+
'KillMode=mixed',
|
|
123
|
+
'TimeoutStopSec=30',
|
|
124
|
+
'Restart=on-failure',
|
|
125
|
+
'RestartSec=10',
|
|
126
|
+
'',
|
|
127
|
+
'[Install]',
|
|
128
|
+
'WantedBy=default.target',
|
|
129
|
+
'',
|
|
130
|
+
].join('\n')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The PowerShell the scheduled task runs at sign-in, in a hidden window: `serve` in the foreground,
|
|
135
|
+
* for as long as it runs. Not `--detach`: a task is not an ssh session, so nothing ends it with one,
|
|
136
|
+
* and it needs no WMI, which some machines block (decision 3).
|
|
137
|
+
*
|
|
138
|
+
* Windows the launcher itself
|
|
139
|
+
* WSL `wsl.exe -d <distro> -u <user> --exec <launcher> serve`: that `wsl.exe` is a client of
|
|
140
|
+
* the distro for as long as the host runs, so WSL does not stop it (§10.4)
|
|
141
|
+
*
|
|
142
|
+
* The variables of `AUTOSTART_ENV` are set in the task (Windows) or passed through `env` (WSL, where
|
|
143
|
+
* the Windows side's environment does not reach the distro).
|
|
144
|
+
*/
|
|
145
|
+
export function taskActionScript({ how, launcher, env, distro = null, user = null }) {
|
|
146
|
+
const carried = AUTOSTART_ENV.filter((k) => env[k])
|
|
147
|
+
if (how === 'wmi') return [...carried.map((k) => `$env:${k} = ${psText(env[k])}`), `& ${psText(launcher)} serve`].join('\n')
|
|
148
|
+
const args = ['-d', wslDistro(distro), '-u', wslUser(user), '--cd', '~', '--exec', ...(carried.length ? ['/usr/bin/env', ...carried.map((k) => `${k}=${env[k]}`)] : []), launcher, 'serve']
|
|
149
|
+
// PowerShell writes the command line itself; what windowsArg could not quote, it would not either
|
|
150
|
+
for (const a of args) windowsArg(a)
|
|
151
|
+
return [`$a = @(${args.map(psText).join(', ')})`, "& (Join-Path $env:SystemRoot 'System32\\wsl.exe') @a"].join('\n')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const TASK_WORD = 'CENTRALU-AUTOSTART'
|
|
155
|
+
|
|
156
|
+
/**
|
|
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).
|
|
161
|
+
*/
|
|
162
|
+
export function taskScript(action, { name, run = '' }) {
|
|
163
|
+
const head = ["$ErrorActionPreference = 'Stop'", PS_MODULE_PATH, '[Console]::OutputEncoding = [Text.Encoding]::UTF8', `$name = ${psText(name)}`]
|
|
164
|
+
const fail = `} catch { "${TASK_WORD}-ERROR $($_.Exception.Message -replace '\\s+', ' ')" }`
|
|
165
|
+
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')
|
|
167
|
+
if (action === 'off') {
|
|
168
|
+
return [...head, 'try {', ` ${find}`, " if ($t) { Unregister-ScheduledTask -TaskName $name -TaskPath '\\' -Confirm:$false }", ` '${TASK_WORD} off'`, fail].join('\n')
|
|
169
|
+
}
|
|
170
|
+
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')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** What a task script printed: `{ ok: true, word }` (`on`, `off`, or the state after `state`) or `{ ok: false, message }` */
|
|
188
|
+
export function parseTaskAnswer(stdout) {
|
|
189
|
+
const text = String(stdout ?? '')
|
|
190
|
+
const e = new RegExp(`${TASK_WORD}-ERROR (.*)`).exec(text)
|
|
191
|
+
if (e) return { ok: false, message: e[1].trim() }
|
|
192
|
+
const m = new RegExp(`${TASK_WORD} (?:state )?(\\S+)`).exec(text)
|
|
193
|
+
if (m) return { ok: true, word: m[1] }
|
|
194
|
+
return { ok: false, message: 'PowerShell did not answer' }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** `{ how, name, linger }` from `AUTOSTART_FILE`, or null */
|
|
198
|
+
export function readAutostartMark(dataDir) {
|
|
199
|
+
try {
|
|
200
|
+
const v = JSON.parse(readFileSync(join(dataDir, AUTOSTART_FILE), 'utf8'))
|
|
201
|
+
if ((v?.how !== 'systemd' && v?.how !== 'task') || typeof v.name !== 'string') return null
|
|
202
|
+
return { how: v.how, name: v.name, linger: typeof v.linger === 'boolean' ? v.linger : null }
|
|
203
|
+
} catch {
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function writeAutostartMark(dataDir, mark) {
|
|
209
|
+
mkdirSync(dataDir, { recursive: true })
|
|
210
|
+
const file = join(dataDir, AUTOSTART_FILE)
|
|
211
|
+
replaceFile(file, `${JSON.stringify({ v: 1, ...mark, at: Date.now() })}\n`)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function clearAutostartMark(dataDir) {
|
|
215
|
+
rmSync(join(dataDir, AUTOSTART_FILE), { force: true })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The connection line's `autostart` field (additive): `{ on, how, linger }`. `how` is what this
|
|
220
|
+
* machine would use (null where it offers nothing), `on` whether `--autostart on` wrote an entry of
|
|
221
|
+
* that kind, `linger` (systemd only) whether the user's manager starts at boot. From the mark only:
|
|
222
|
+
* this runs at every link start.
|
|
223
|
+
*/
|
|
224
|
+
export function autostartInfo(dataDir, platform, env, how = autostartHow(platform, env)) {
|
|
225
|
+
const mark = readAutostartMark(dataDir)
|
|
226
|
+
const on = how !== null && mark?.how === how
|
|
227
|
+
return { on, how, linger: on && how === 'systemd' ? mark.linger : null }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The sentence for a user manager that starts only at login (`loginctl enable-linger` refused) */
|
|
231
|
+
export function lingerMessage(user) {
|
|
232
|
+
return `systemd starts it when ${user} logs in, not at boot, until an administrator runs \`sudo loginctl enable-linger ${user}\` there`
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** systemctl --user over ssh needs the user's runtime folder; a session without pam_systemd may not name it */
|
|
236
|
+
function userManagerEnv(env) {
|
|
237
|
+
if (env.XDG_RUNTIME_DIR || typeof process.getuid !== 'function') return env
|
|
238
|
+
const dir = `/run/user/${process.getuid()}`
|
|
239
|
+
return existsSync(dir) ? { ...env, XDG_RUNTIME_DIR: dir } : env
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function systemdAutostart(action, { dataDir, env, home, name, run, user }) {
|
|
243
|
+
const dir = join(env.XDG_CONFIG_HOME || join(home, '.config'), 'systemd', 'user')
|
|
244
|
+
const file = join(dir, name)
|
|
245
|
+
const ctlEnv = userManagerEnv(env)
|
|
246
|
+
const ctl = (...args) => run('systemctl', ['--user', ...args], ctlEnv)
|
|
247
|
+
const lingers = async () => {
|
|
248
|
+
const r = await run('loginctl', ['show-user', user, '--property=Linger', '--value'], ctlEnv)
|
|
249
|
+
return r.code === 0 ? r.stdout.trim() === 'yes' : null
|
|
250
|
+
}
|
|
251
|
+
const base = { ok: true, how: 'systemd' }
|
|
252
|
+
if (action === 'status') {
|
|
253
|
+
const on = existsSync(file) && (await ctl('is-enabled', name)).stdout.trim() === 'enabled'
|
|
254
|
+
const linger = on ? await lingers() : null
|
|
255
|
+
if (on) writeAutostartMark(dataDir, { how: 'systemd', name, linger })
|
|
256
|
+
else clearAutostartMark(dataDir)
|
|
257
|
+
return { ...base, on, linger }
|
|
258
|
+
}
|
|
259
|
+
if (action === 'off') {
|
|
260
|
+
if (existsSync(file)) {
|
|
261
|
+
await ctl('disable', name)
|
|
262
|
+
rmSync(file, { force: true })
|
|
263
|
+
// Left by an `enable` whose `disable` did not reach the manager; harmless, but not ours to leave
|
|
264
|
+
rmSync(join(dir, 'default.target.wants', name), { force: true })
|
|
265
|
+
await ctl('daemon-reload')
|
|
266
|
+
}
|
|
267
|
+
clearAutostartMark(dataDir)
|
|
268
|
+
return { ...base, on: false, linger: null }
|
|
269
|
+
}
|
|
270
|
+
const launcher = autostartLauncher(dataDir, 'linux')
|
|
271
|
+
if (!launcher) return { ...base, ok: false, on: false, linger: null, message: `there is no Centralu launcher in ${dataDir} to start at boot` }
|
|
272
|
+
mkdirSync(dir, { recursive: true })
|
|
273
|
+
replaceFile(file, systemdUnit({ launcher, env }))
|
|
274
|
+
let r = await ctl('daemon-reload')
|
|
275
|
+
if (r.code === 0) r = await ctl('enable', name)
|
|
276
|
+
if (r.code !== 0) {
|
|
277
|
+
rmSync(file, { force: true })
|
|
278
|
+
await ctl('daemon-reload')
|
|
279
|
+
clearAutostartMark(dataDir)
|
|
280
|
+
const why = String(r.stderr).trim().split(/\r?\n/).at(-1) || `exit ${r.code}`
|
|
281
|
+
return { ...base, ok: false, on: false, linger: null, message: `systemd did not take the unit: ${why}` }
|
|
282
|
+
}
|
|
283
|
+
let linger = await lingers()
|
|
284
|
+
if (linger === false) {
|
|
285
|
+
await run('loginctl', ['--no-ask-password', 'enable-linger', user], ctlEnv)
|
|
286
|
+
linger = await lingers()
|
|
287
|
+
}
|
|
288
|
+
writeAutostartMark(dataDir, { how: 'systemd', name, linger })
|
|
289
|
+
return { ...base, on: true, linger, ...(linger === true ? {} : { message: lingerMessage(user) }) }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function taskAutostart(action, { dataDir, env, name, run, user, platform, interop }) {
|
|
293
|
+
const detach = detachHow(platform, env, interop)
|
|
294
|
+
const ps = windowsPowershell(detach, env)
|
|
295
|
+
const base = { ok: true, how: 'task', linger: null }
|
|
296
|
+
if (!ps) return { ...base, ok: false, on: false, message: 'Windows PowerShell is not reachable from this WSL distro' }
|
|
297
|
+
const ask = (script) => askPowershell(ps, script, env, parseTaskAnswer, run)
|
|
298
|
+
if (action === 'status') {
|
|
299
|
+
const a = await ask(taskScript('status', { name }))
|
|
300
|
+
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'
|
|
302
|
+
if (on) writeAutostartMark(dataDir, { how: 'task', name, linger: null })
|
|
303
|
+
else clearAutostartMark(dataDir)
|
|
304
|
+
return { ...base, on }
|
|
305
|
+
}
|
|
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}` }
|
|
309
|
+
clearAutostartMark(dataDir)
|
|
310
|
+
return { ...base, on: false }
|
|
311
|
+
}
|
|
312
|
+
const launcher = autostartLauncher(dataDir, platform)
|
|
313
|
+
if (!launcher) return { ...base, ok: false, on: false, message: `there is no Centralu launcher in ${dataDir} to start at sign-in` }
|
|
314
|
+
const runScript = taskActionScript({ how: detach, launcher, env, distro: env.WSL_DISTRO_NAME ?? null, user })
|
|
315
|
+
const a = await ask(taskScript('on', { name, run: runScript }))
|
|
316
|
+
if (!a.ok) return { ...base, ok: false, on: false, message: `Task Scheduler did not take the task: ${a.message}` }
|
|
317
|
+
writeAutostartMark(dataDir, { how: 'task', name, linger: null })
|
|
318
|
+
return { ...base, on: true }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* `centralu serve --autostart on|off|status` (docs/plans/remote-hub.md §10.4, owner decision 4: per
|
|
323
|
+
* 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
|
|
325
|
+
* hub asks (the link started it), and the entry is for the next boot.
|
|
326
|
+
*
|
|
327
|
+
* stdout: one line, `{ v: 1, autostart: { ok, on, how, linger, message? } }`; `how` null where this
|
|
328
|
+
* machine offers nothing (`autostartHow`). `run` (a command runner) and `how` are parameters so
|
|
329
|
+
* tests never reach the real systemd or Task Scheduler.
|
|
330
|
+
*/
|
|
331
|
+
export async function runAutostart({
|
|
332
|
+
env,
|
|
333
|
+
home,
|
|
334
|
+
action,
|
|
335
|
+
cliPath,
|
|
336
|
+
platform = process.platform,
|
|
337
|
+
interop = wslInterop,
|
|
338
|
+
how = autostartHow(platform, env, { interop }),
|
|
339
|
+
run = runCaptured,
|
|
340
|
+
user = userInfo().username,
|
|
341
|
+
write = (line) => process.stdout.write(line),
|
|
342
|
+
}) {
|
|
343
|
+
const dataDir = serveDataDir(env, home)
|
|
344
|
+
// The entry of an npm install runs the launcher serve keeps; make sure it is there and current
|
|
345
|
+
keepLauncher(dataDir, cliPath, env)
|
|
346
|
+
const report = (a) => answerLine(write, 'autostart', a)
|
|
347
|
+
if (how === null) {
|
|
348
|
+
clearAutostartMark(dataDir)
|
|
349
|
+
return report({ ok: action !== 'on', on: false, how: null, linger: null, ...(action === 'off' ? {} : { message: autostartUnsupported(platform, env) }) })
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
const name = autostartName(how, { dataDir, home, env, platform })
|
|
353
|
+
const opts = { dataDir, env, home, name, run, user, platform, interop }
|
|
354
|
+
return report(how === 'systemd' ? await systemdAutostart(action, opts) : await taskAutostart(action, opts))
|
|
355
|
+
} catch (e) {
|
|
356
|
+
return report({ ok: false, on: readAutostartMark(dataDir)?.how === how, how, linger: null, message: e?.message ?? String(e) })
|
|
357
|
+
}
|
|
358
|
+
}
|