centralu 0.1.0-beta.13 → 0.1.0-beta.15
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 +30 -20
- package/bin/platform.mjs +40 -0
- package/bin/semver.mjs +9 -1
- package/bin/serve.mjs +476 -12
- package/bin/swap.mjs +48 -0
- package/package.json +5 -5
package/bin/centralu.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
busyMessage,
|
|
30
30
|
earlyExitMessage,
|
|
31
31
|
executableIn,
|
|
32
|
-
|
|
32
|
+
findHostEntry,
|
|
33
33
|
installedPaths,
|
|
34
34
|
isBusyError,
|
|
35
35
|
launchPlan,
|
|
@@ -47,12 +47,14 @@ import {
|
|
|
47
47
|
parseServeArgs,
|
|
48
48
|
printConnection,
|
|
49
49
|
rotateToken,
|
|
50
|
+
runAutostart,
|
|
50
51
|
runDetach,
|
|
51
52
|
runDetachedChild,
|
|
52
53
|
runServe,
|
|
53
54
|
runStop,
|
|
54
55
|
SERVE_HELP,
|
|
55
56
|
} from './serve.mjs'
|
|
57
|
+
import { swapInto } from './swap.mjs'
|
|
56
58
|
import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
57
59
|
import { createRequire } from 'node:module'
|
|
58
60
|
import { homedir } from 'node:os'
|
|
@@ -118,7 +120,8 @@ function explainMissing() {
|
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
/**
|
|
121
|
-
* The bundled host's entry (`resources/host/main.mjs`) for `centralu serve`, or exit with why not
|
|
123
|
+
* The bundled host's entry (`resources/host/main.mjs`) for `centralu serve`, or exit with why not:
|
|
124
|
+
* from the platform package, or from the host-only package where a hub installed this (`findHostEntry`).
|
|
122
125
|
*
|
|
123
126
|
* `CENTRALU_HOST_ENTRY` points it at another host instead: a source checkout's
|
|
124
127
|
* `packages/agent-host/src/main.ts` while developing, or a freshly bundled `resources/host/main.mjs`.
|
|
@@ -130,22 +133,27 @@ function requireHostEntry() {
|
|
|
130
133
|
console.error(explainMissing())
|
|
131
134
|
process.exit(1)
|
|
132
135
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
136
|
+
// The platform package of an npm install, else the host-only package a hub installed (platform.mjs)
|
|
137
|
+
const found = findHostEntry(PLATFORM, process.arch, {
|
|
138
|
+
resolveRoot: (name) => {
|
|
139
|
+
try {
|
|
140
|
+
return dirname(require.resolve(`${name}/package.json`))
|
|
141
|
+
} catch {
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
exists: existsSync,
|
|
146
|
+
})
|
|
147
|
+
if (found.entry) return found.entry
|
|
148
|
+
if (found.missingHost) {
|
|
142
149
|
console.error(
|
|
143
|
-
`The host is missing from ${
|
|
150
|
+
`The host is missing from ${found.missingHost.pkg} (looked for ${found.missingHost.entry}).\n` +
|
|
144
151
|
'Linux packages published before `centralu serve` existed do not carry it. Update with `npm i -g centralu`.',
|
|
145
152
|
)
|
|
146
153
|
process.exit(1)
|
|
147
154
|
}
|
|
148
|
-
|
|
155
|
+
console.error(explainMissing())
|
|
156
|
+
process.exit(1)
|
|
149
157
|
}
|
|
150
158
|
|
|
151
159
|
function requireApp() {
|
|
@@ -272,13 +280,14 @@ function install() {
|
|
|
272
280
|
const app = requireApp()
|
|
273
281
|
if (PLATFORM === 'win32') return installWindows(app)
|
|
274
282
|
if (PLATFORM !== 'darwin') return installDesktopEntry(app)
|
|
275
|
-
if (existsSync(INSTALLED)) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
283
|
+
if (existsSync(INSTALLED)) console.log(`Replacing the existing ${INSTALLED} with the new version.`)
|
|
284
|
+
// Copied beside the old one and swapped in by rename (swap.mjs): a copy that fails leaves the
|
|
285
|
+
// old app in place rather than none. ditto, not cp — carries over the bundle's permissions and
|
|
286
|
+
// extended attributes unchanged (so the signature does not break).
|
|
287
|
+
const { leftAt } = swapInto(INSTALLED, (staging) => {
|
|
288
|
+
execFileSync('/usr/bin/ditto', [app, staging], { stdio: 'inherit' })
|
|
289
|
+
})
|
|
290
|
+
if (leftAt) console.log(`The previous copy could not be removed and was left at ${leftAt}. Delete it yourself.`)
|
|
282
291
|
console.log(`Installed: ${INSTALLED}`)
|
|
283
292
|
console.log('It can now be found in Launchpad and Spotlight too.')
|
|
284
293
|
}
|
|
@@ -602,6 +611,7 @@ switch (cmd) {
|
|
|
602
611
|
if (args.mode === 'connection') process.exitCode = await printConnection(opts)
|
|
603
612
|
else if (args.mode === 'detach') process.exitCode = await runDetach({ ...opts, port: args.port })
|
|
604
613
|
else if (args.mode === 'stop') process.exitCode = await runStop(opts)
|
|
614
|
+
else if (args.mode === 'autostart') process.exitCode = await runAutostart({ ...opts, action: args.autostart })
|
|
605
615
|
else if (spec) process.exitCode = await runDetachedChild({ ...opts, spec, port: args.port })
|
|
606
616
|
else process.exitCode = await runServe({ ...opts, port: args.port })
|
|
607
617
|
} catch (e) {
|
package/bin/platform.mjs
CHANGED
|
@@ -70,6 +70,46 @@ export function hostDirIn(platform, app) {
|
|
|
70
70
|
return posix.join(posix.dirname(app), 'host')
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* The host alone, without the window, for the machines a hub installs on (docs/plans/remote-hub.md
|
|
75
|
+
* §10.2, owner decision 2 of §10.9): `host/` at the top of the package on every platform. The shim
|
|
76
|
+
* never pins these (an npm install gets the host inside its platform package already); a hub's
|
|
77
|
+
* installer fetches one beside this package instead of the platform package, so a Linux remote does
|
|
78
|
+
* not download an 81 MB AppImage to throw away. Published from the same release jobs as the platform
|
|
79
|
+
* packages (`scripts/host-package.mts`); `tooling/host-package.test.ts` holds this table to them.
|
|
80
|
+
*/
|
|
81
|
+
export const HOST_PACKAGES = {
|
|
82
|
+
'linux-arm64': '@centralu/host-linux-arm64',
|
|
83
|
+
'linux-x64': '@centralu/host-linux-x64',
|
|
84
|
+
'win32-x64': '@centralu/host-win32-x64',
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Where `centralu serve` finds the host's `main.mjs`: in the platform package (an npm install), else
|
|
89
|
+
* in the host-only package (what a hub installed, `<data>/remote/versions/<v>/node_modules/`).
|
|
90
|
+
*
|
|
91
|
+
* `resolveRoot(name)` is the package's folder or null when it is not installed; `exists(file)` says
|
|
92
|
+
* whether a file is there. Answers `{ entry }`, or `{ missingHost: { pkg, entry } }` when a package
|
|
93
|
+
* is installed without its host, or `{ missingPackage: true }` when neither is installed.
|
|
94
|
+
*/
|
|
95
|
+
export function findHostEntry(platform, arch, { resolveRoot, exists }) {
|
|
96
|
+
const join = platform === 'win32' ? win32.join : posix.join
|
|
97
|
+
const target = targetFor(platform, arch)
|
|
98
|
+
const hostOnly = HOST_PACKAGES[`${platform}-${arch}`]
|
|
99
|
+
const places = []
|
|
100
|
+
if (target) places.push({ pkg: target.pkg, dir: (root) => hostDirIn(platform, join(root, target.artifact)) })
|
|
101
|
+
if (hostOnly) places.push({ pkg: hostOnly, dir: (root) => join(root, 'host') })
|
|
102
|
+
let missingHost = null
|
|
103
|
+
for (const place of places) {
|
|
104
|
+
const root = resolveRoot(place.pkg)
|
|
105
|
+
if (!root) continue
|
|
106
|
+
const entry = join(place.dir(root), 'main.mjs')
|
|
107
|
+
if (exists(entry)) return { entry }
|
|
108
|
+
missingHost ??= { pkg: place.pkg, entry }
|
|
109
|
+
}
|
|
110
|
+
return missingHost ? { missingHost } : { missingPackage: true }
|
|
111
|
+
}
|
|
112
|
+
|
|
73
113
|
/**
|
|
74
114
|
* How to start the app: the command, its arguments, the spawn options, and whether the
|
|
75
115
|
* launcher stays attached until it exits.
|
package/bin/semver.mjs
CHANGED
|
@@ -68,7 +68,15 @@ export function copyDiffers(pkgVersion, copyVersion) {
|
|
|
68
68
|
return typeof copyVersion === 'string' && copyVersion !== '' && copyVersion !== pkgVersion
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/*
|
|
72
|
+
* Build metadata (`+sha`) is dropped first: semver ignores it for precedence, and left in, it turned `3+build5`
|
|
73
|
+
* into NaN (and the `-` in `+build-5` into a prerelease). A leading `v` (`v1.2.3`, as a tag is written) is dropped
|
|
74
|
+
* too, or the first slot is NaN. Either NaN ends the comparison as "not newer", the #42 failure again.
|
|
75
|
+
*/
|
|
76
|
+
function split(raw) {
|
|
77
|
+
let v = raw.trim().replace(/^v/i, '')
|
|
78
|
+
const plus = v.indexOf('+')
|
|
79
|
+
if (plus !== -1) v = v.slice(0, plus)
|
|
72
80
|
const dash = v.indexOf('-')
|
|
73
81
|
const core = (dash === -1 ? v : v.slice(0, dash)).split('.').map(Number)
|
|
74
82
|
const pre = dash === -1 ? null : v.slice(dash + 1)
|
package/bin/serve.mjs
CHANGED
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
* directory, so this ships automatically.
|
|
17
17
|
*/
|
|
18
18
|
import { spawn } from 'node:child_process'
|
|
19
|
-
import { randomBytes } from 'node:crypto'
|
|
19
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
20
20
|
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync, closeSync } from 'node:fs'
|
|
21
|
-
import { connect } from 'node:net'
|
|
21
|
+
import { connect, createServer } from 'node:net'
|
|
22
22
|
import { userInfo } from 'node:os'
|
|
23
|
-
import { dirname, join } from 'node:path'
|
|
23
|
+
import { dirname, join, posix } from 'node:path'
|
|
24
24
|
import { createInterface } from 'node:readline'
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -34,6 +34,70 @@ import { createInterface } from 'node:readline'
|
|
|
34
34
|
*/
|
|
35
35
|
export const DEFAULT_SERVE_PORT = 17175
|
|
36
36
|
|
|
37
|
+
/** How many ports from the recorded one `serve` tries when it is taken (#482); none at or above 20000 */
|
|
38
|
+
export const SERVE_PORT_TRIES = 20
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The port `serve` starts the host on (#482): `{ port, from }`, `from` being the port it moved away
|
|
42
|
+
* from (null when it did not), or null when every candidate is taken.
|
|
43
|
+
*
|
|
44
|
+
* A port the person gave (`--port`) is used as given: they asked for that one. Otherwise the recorded
|
|
45
|
+
* port (else the default) when it is free, and when another program listens there, the next free
|
|
46
|
+
* one above it, below 20000 (the ranges `DEFAULT_SERVE_PORT` keeps out of). The host's ready line
|
|
47
|
+
* then records the port it got, so `--connection` reports it and the hub forwards to it.
|
|
48
|
+
*
|
|
49
|
+
* The usual other program is the other half of one Windows laptop: WSL2 relays a distro's
|
|
50
|
+
* 127.0.0.1 ports to Windows (`wslrelay.exe`), so a Windows install and a WSL install linked as two
|
|
51
|
+
* machines both wanted 17175, and whichever started second could not start at all.
|
|
52
|
+
* `taken(port)` answers whether something listens there.
|
|
53
|
+
*/
|
|
54
|
+
export async function chooseServePort({ asked, recorded, taken }) {
|
|
55
|
+
if (asked !== null && asked !== undefined) return { port: asked, from: null }
|
|
56
|
+
const first = recorded ?? DEFAULT_SERVE_PORT
|
|
57
|
+
for (let p = first; p < first + SERVE_PORT_TRIES && p <= 65535; p++) {
|
|
58
|
+
if (p !== first && p >= 20000) break
|
|
59
|
+
if (!(await taken(p))) return { port: p, from: p === first ? null : first }
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whether something listens on 127.0.0.1:`port` here: a listen that fails with EADDRINUSE (or EACCES) */
|
|
65
|
+
export function listensHere(port) {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
const srv = createServer()
|
|
68
|
+
srv.once('error', (e) => resolve(e?.code === 'EADDRINUSE' || e?.code === 'EACCES'))
|
|
69
|
+
srv.listen({ host: '127.0.0.1', port, exclusive: true }, () => srv.close(() => resolve(false)))
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The TCP ports Windows listens on, from `netstat.exe -an -p TCP` output: a listening socket is the
|
|
75
|
+
* row whose foreign address is `0.0.0.0:0` (or `[::]:0`). Read that way rather than by the state
|
|
76
|
+
* column, which Windows translates (a Korean Windows does not print LISTENING everywhere).
|
|
77
|
+
*/
|
|
78
|
+
export function parseNetstatListening(text) {
|
|
79
|
+
const ports = new Set()
|
|
80
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
81
|
+
const m = /^\s*TCP\s+(?:127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d+)\s+(?:0\.0\.0\.0:0|\[::\]:0)\s/i.exec(line)
|
|
82
|
+
if (m) ports.add(Number(m[1]))
|
|
83
|
+
}
|
|
84
|
+
return ports
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The ports the Windows side listens on, seen from inside a WSL distro (empty elsewhere, or when
|
|
89
|
+
* Windows cannot be asked). Inside the distro a port Windows holds looks free, yet a host there
|
|
90
|
+
* would be unreachable from the hub: the hub's forward ends on Windows' 127.0.0.1, where the
|
|
91
|
+
* Windows program answers instead (#482).
|
|
92
|
+
*/
|
|
93
|
+
async function windowsListening(env, platform) {
|
|
94
|
+
if (detachHow(platform, env) !== 'wsl') return new Set()
|
|
95
|
+
const netstat = windowsTool('wsl', env, ['System32', 'netstat.exe'])
|
|
96
|
+
if (!netstat) return new Set()
|
|
97
|
+
const r = await runCaptured(netstat, ['-an', '-p', 'TCP'], env)
|
|
98
|
+
return r.code === 0 ? parseNetstatListening(r.stdout) : new Set()
|
|
99
|
+
}
|
|
100
|
+
|
|
37
101
|
/**
|
|
38
102
|
* The file in the data folder that holds the token and the port, mode 0600.
|
|
39
103
|
*
|
|
@@ -58,6 +122,11 @@ export const SERVE_HELP = `centralu serve: run the Centralu host on this machine
|
|
|
58
122
|
centralu serve --stop Ask the running host to stop, and wait until it has
|
|
59
123
|
centralu serve --connection Print how to reach it as one JSON line, then exit
|
|
60
124
|
centralu serve --rotate-token Replace the token (a running serve keeps the old one until restarted)
|
|
125
|
+
centralu serve --autostart on|off|status
|
|
126
|
+
Start it when this machine starts (Linux: a systemd user unit;
|
|
127
|
+
Windows and WSL: a scheduled task at sign-in), stop doing that,
|
|
128
|
+
or say whether it does. Turning it on or off leaves a host that
|
|
129
|
+
runs now as it is
|
|
61
130
|
|
|
62
131
|
The data folder is ~/.centralu (CC_DATA_DIR overrides it). The app on another computer reaches
|
|
63
132
|
this host through an SSH local forward, and starts it with --detach when it is not running.
|
|
@@ -70,7 +139,10 @@ from an install the app made over SSH, which has a launcher of its own.`
|
|
|
70
139
|
|
|
71
140
|
/** The flags that each make serve a different command */
|
|
72
141
|
const MODE_FLAGS = { '--connection': 'connection', '--rotate-token': 'rotate', '--detach': 'detach', '--stop': 'stop' }
|
|
73
|
-
const FLAG_OF = Object.fromEntries(Object.entries(MODE_FLAGS).map(([f, m]) => [m, f]))
|
|
142
|
+
const FLAG_OF = { ...Object.fromEntries(Object.entries(MODE_FLAGS).map(([f, m]) => [m, f])), autostart: '--autostart' }
|
|
143
|
+
|
|
144
|
+
/** What `--autostart` takes */
|
|
145
|
+
export const AUTOSTART_ACTIONS = ['on', 'off', 'status']
|
|
74
146
|
|
|
75
147
|
/**
|
|
76
148
|
* The argument a detached launcher is started with: what it needs from the `--detach` that started
|
|
@@ -80,21 +152,29 @@ const FLAG_OF = Object.fromEntries(Object.entries(MODE_FLAGS).map(([f, m]) => [m
|
|
|
80
152
|
const CHILD_FLAG = '--detached-child='
|
|
81
153
|
|
|
82
154
|
/**
|
|
83
|
-
* What the arguments after `serve` ask for: `{ mode, port }` (plus `child` for a detached launcher
|
|
84
|
-
* or `{ error }`.
|
|
155
|
+
* What the arguments after `serve` ask for: `{ mode, port }` (plus `child` for a detached launcher,
|
|
156
|
+
* `autostart` for `--autostart`) or `{ error }`.
|
|
85
157
|
*
|
|
86
|
-
* `mode` is `serve`, `detach`, `stop`, `connection`, `rotate` or `help`; `port` is null
|
|
87
|
-
* given. `--port` goes with `serve` and `--detach` only: `--connection` has to report the
|
|
88
|
-
* `serve` uses, not one the caller names,
|
|
158
|
+
* `mode` is `serve`, `detach`, `stop`, `connection`, `rotate`, `autostart` or `help`; `port` is null
|
|
159
|
+
* when not given. `--port` goes with `serve` and `--detach` only: `--connection` has to report the
|
|
160
|
+
* port `serve` uses, not one the caller names, `--stop` stops the one that runs, and the entry
|
|
161
|
+
* `--autostart` writes starts serve on the port it recorded.
|
|
89
162
|
*/
|
|
90
163
|
export function parseServeArgs(argv) {
|
|
91
164
|
let mode = 'serve'
|
|
92
165
|
let port = null
|
|
93
166
|
let help = false
|
|
94
167
|
let child = null
|
|
168
|
+
let autostart = null
|
|
95
169
|
for (let i = 0; i < argv.length; i++) {
|
|
96
170
|
const a = argv[i]
|
|
97
|
-
if (
|
|
171
|
+
if (a === '--autostart' || a.startsWith('--autostart=')) {
|
|
172
|
+
const raw = a === '--autostart' ? argv[++i] : a.slice('--autostart='.length)
|
|
173
|
+
if (!AUTOSTART_ACTIONS.includes(raw)) return { error: `--autostart needs on, off or status (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
|
|
174
|
+
if (mode !== 'serve' && mode !== 'autostart') return { error: `${FLAG_OF[mode]} and --autostart are separate commands; pass one` }
|
|
175
|
+
mode = 'autostart'
|
|
176
|
+
autostart = raw
|
|
177
|
+
} else if (Object.hasOwn(MODE_FLAGS, a)) {
|
|
98
178
|
const m = MODE_FLAGS[a]
|
|
99
179
|
if (mode !== 'serve' && mode !== m) return { error: `${FLAG_OF[mode]} and ${a} are separate commands; pass one` }
|
|
100
180
|
mode = m
|
|
@@ -116,6 +196,10 @@ export function parseServeArgs(argv) {
|
|
|
116
196
|
return { error: '--port goes with `centralu serve`; `--connection` reports the port serve listens on' }
|
|
117
197
|
}
|
|
118
198
|
if (mode === 'stop' && port !== null) return { error: '--stop stops the serve that is running, on the port it recorded; it takes no --port' }
|
|
199
|
+
if (mode === 'autostart') {
|
|
200
|
+
if (port !== null) return { error: '--autostart takes no --port: the entry it writes starts serve on the port serve recorded' }
|
|
201
|
+
return { mode, port, autostart }
|
|
202
|
+
}
|
|
119
203
|
return { mode, port }
|
|
120
204
|
}
|
|
121
205
|
|
|
@@ -314,7 +398,7 @@ export function protocolVersionOf(entry) {
|
|
|
314
398
|
* `install` (additive, the line stays `v: 1`) says what runs this command (`installInfo`); `detach`
|
|
315
399
|
* is added by `--detach` only.
|
|
316
400
|
*/
|
|
317
|
-
export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, detach }) {
|
|
401
|
+
export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, autostart, detach }) {
|
|
318
402
|
return JSON.stringify({
|
|
319
403
|
v: 1,
|
|
320
404
|
port,
|
|
@@ -324,6 +408,7 @@ export function connectionLine({ port, token, version, protocolVersion, dataDir,
|
|
|
324
408
|
dataDir,
|
|
325
409
|
hostRunning,
|
|
326
410
|
...(install ? { install } : {}),
|
|
411
|
+
...(autostart ? { autostart } : {}),
|
|
327
412
|
...(detach ? { detach } : {}),
|
|
328
413
|
})
|
|
329
414
|
}
|
|
@@ -614,6 +699,7 @@ async function describe({ env, dataDir, state, entry, version }) {
|
|
|
614
699
|
dataDir,
|
|
615
700
|
hostRunning: probe.running,
|
|
616
701
|
install: installInfo(dataDir, env),
|
|
702
|
+
autostart: autostartInfo(dataDir, process.platform, env),
|
|
617
703
|
}
|
|
618
704
|
}
|
|
619
705
|
|
|
@@ -669,7 +755,6 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
669
755
|
const dataDir = serveDataDir(env, home)
|
|
670
756
|
const state = ensureServeState(dataDir, platform)
|
|
671
757
|
keepLauncher(dataDir, cliPath, env)
|
|
672
|
-
const port = askedPort ?? state.port ?? DEFAULT_SERVE_PORT
|
|
673
758
|
const protocolVersion = protocolVersionOf(entry)
|
|
674
759
|
|
|
675
760
|
// A serve that is already up answers on the port it recorded; say so before starting a host that
|
|
@@ -682,6 +767,18 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
|
|
|
682
767
|
return 1
|
|
683
768
|
}
|
|
684
769
|
|
|
770
|
+
const onWindows = await windowsListening(env, platform)
|
|
771
|
+
const chosen = await chooseServePort({ asked: askedPort, recorded: state.port, taken: async (p) => onWindows.has(p) || (await listensHere(p)) })
|
|
772
|
+
if (chosen === null) {
|
|
773
|
+
say(`another program listens on 127.0.0.1:${recorded} and on the ${SERVE_PORT_TRIES - 1} ports above it. Give a free one with --port <n>.`)
|
|
774
|
+
return 1
|
|
775
|
+
}
|
|
776
|
+
const port = chosen.port
|
|
777
|
+
if (chosen.from !== null) {
|
|
778
|
+
const who = onWindows.has(chosen.from) ? 'Windows' : 'another program'
|
|
779
|
+
say(`127.0.0.1:${chosen.from} is in use by ${who}; starting on ${port} instead (kept for the next start)`)
|
|
780
|
+
}
|
|
781
|
+
|
|
685
782
|
const plan = hostCommand(execPath, entry, { port, dataDir, platform })
|
|
686
783
|
const child = spawn(plan.command, plan.args, {
|
|
687
784
|
stdio: ['pipe', 'pipe', logFd === null ? 'inherit' : 'pipe'],
|
|
@@ -1243,3 +1340,370 @@ export async function runStop({
|
|
|
1243
1340
|
if (!stopped) return report({ ok: false, wasRunning: true, how, message: `the host on 127.0.0.1:${port} is still running${note ? ` (${note})` : ''}` })
|
|
1244
1341
|
return report({ ok: true, wasRunning: true, how, ...(note ? { message: note } : {}) })
|
|
1245
1342
|
}
|
|
1343
|
+
|
|
1344
|
+
// ── --autostart ─────────────────────────────────────────────────────────────────────────────
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* What `--autostart on` records in the data folder: the entry it wrote, so `--connection` can say
|
|
1348
|
+
* whether this machine starts its host at boot without asking systemd or Task Scheduler at every
|
|
1349
|
+
* link start. `--autostart status` asks them, and corrects this file when it disagrees.
|
|
1350
|
+
*/
|
|
1351
|
+
export const AUTOSTART_FILE = 'autostart.json'
|
|
1352
|
+
|
|
1353
|
+
/** The variables the entry carries to the serve it starts: the data folder, and a host entry (development) */
|
|
1354
|
+
export const AUTOSTART_ENV = ['CC_DATA_DIR', 'CENTRALU_HOST_ENTRY']
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* How this machine would start `serve` at boot (docs/plans/remote-hub.md §10.4, owner decision 4),
|
|
1358
|
+
* or null where it offers none:
|
|
1359
|
+
*
|
|
1360
|
+
* systemd Linux: a `systemd --user` unit, enabled for `default.target`, plus lingering so the
|
|
1361
|
+
* user's manager starts at boot rather than at the first login
|
|
1362
|
+
* task Windows: a per-user scheduled task at sign-in (no administrator needed). Also inside
|
|
1363
|
+
* WSL: nothing in a stopped distro runs, so the Windows side has to start it, through
|
|
1364
|
+
* `wsl.exe`, which then holds the distro for as long as the host runs
|
|
1365
|
+
*
|
|
1366
|
+
* WSL with interop off has no way to the Windows side, and a Linux without systemd (or macOS) has
|
|
1367
|
+
* no user unit; those say so instead.
|
|
1368
|
+
*/
|
|
1369
|
+
export function autostartHow(platform, env, { interop = wslInterop, systemd = () => existsSync('/run/systemd/system') } = {}) {
|
|
1370
|
+
if (platform === 'win32') return 'task'
|
|
1371
|
+
if (platform !== 'linux') return null
|
|
1372
|
+
if (env.WSL_DISTRO_NAME) return interop() ? 'task' : null
|
|
1373
|
+
return systemd() ? 'systemd' : null
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/** Why `autostartHow` found nothing, in one sentence */
|
|
1377
|
+
export function autostartUnsupported(platform, env) {
|
|
1378
|
+
if (platform === 'linux' && env.WSL_DISTRO_NAME) {
|
|
1379
|
+
return 'Starting Centralu with WSL needs a Windows scheduled task, and this distro cannot run Windows programs (WSL interop is off)'
|
|
1380
|
+
}
|
|
1381
|
+
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'
|
|
1382
|
+
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`
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* A folder path as a comparable key on `platform`: either separator as `/`, `.` and `..` folded, no
|
|
1387
|
+
* trailing separator, and lower case on Windows, whose paths do not differ by case
|
|
1388
|
+
*/
|
|
1389
|
+
export function pathKey(p, platform) {
|
|
1390
|
+
let s = posix.normalize(String(p).replaceAll('\\', '/'))
|
|
1391
|
+
if (s.length > 1) s = s.replace(/\/+$/, '')
|
|
1392
|
+
return platform === 'win32' ? s.toLowerCase() : s
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* The entry's name. One per data folder: the default one gets the plain name, any other (a second
|
|
1397
|
+
* install, a test's temporary folder) a suffix from its path, so they never replace each other. A
|
|
1398
|
+
* Windows laptop linked twice, as Windows and as a WSL distro, gets two tasks.
|
|
1399
|
+
*
|
|
1400
|
+
* Whether `dataDir` is the default is read the same way whatever OS runs this (`pathKey`), not with
|
|
1401
|
+
* this Node's `join`: the folder may be written with either separator (`CC_DATA_DIR=C:/x` on Windows),
|
|
1402
|
+
* and Windows paths do not differ by case, so `C:\Users\Me\.centralu` is the default folder too.
|
|
1403
|
+
*/
|
|
1404
|
+
export function autostartName(how, { dataDir, home, env, platform = process.platform }) {
|
|
1405
|
+
const key = pathKey(dataDir, platform)
|
|
1406
|
+
const own = key === pathKey(`${home}/.centralu`, platform) ? '' : createHash('sha256').update(key).digest('hex').slice(0, 8)
|
|
1407
|
+
if (how === 'systemd') return `centralu-serve${own ? `-${own}` : ''}.service`
|
|
1408
|
+
const distro = env.WSL_DISTRO_NAME
|
|
1409
|
+
if (distro && !/^[A-Za-z0-9._-]{1,64}$/.test(distro)) throw new Error(`not a WSL distro name: ${distro}`)
|
|
1410
|
+
return `Centralu host${distro ? ` (WSL ${distro})` : ''}${own ? ` ${own}` : ''}`
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* What the entry runs: the managed launcher when the app installed Centralu here over ssh (its text
|
|
1415
|
+
* never changes and it reads `current`, so an update needs no new entry), else the launcher `serve`
|
|
1416
|
+
* keeps for an npm install. The order is the hub's lookup (`connectionCommand` in links/tunnel.ts).
|
|
1417
|
+
*/
|
|
1418
|
+
export function autostartLauncher(dataDir, platform, exists = existsSync) {
|
|
1419
|
+
const file = platform === 'win32' ? 'centralu.cmd' : 'centralu'
|
|
1420
|
+
for (const p of [join(remoteLayout(dataDir).bin, file), join(dataDir, 'bin', file)]) if (exists(p)) return p
|
|
1421
|
+
return null
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/** One value for a unit file, double-quoted; `$` doubled where systemd would expand it (ExecStart) */
|
|
1425
|
+
function systemdQuote(value, { dollars = false } = {}) {
|
|
1426
|
+
const s = String(value)
|
|
1427
|
+
if (/[\r\n\0]/.test(s)) throw new Error(`cannot put ${JSON.stringify(s)} in a systemd unit`)
|
|
1428
|
+
let q = s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')
|
|
1429
|
+
if (dollars) q = q.replaceAll('$', '$$$$')
|
|
1430
|
+
return `"${q}"`
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/**
|
|
1434
|
+
* The `systemd --user` unit (docs/agent-host.md §4.7 has the hand-written one this follows).
|
|
1435
|
+
*
|
|
1436
|
+
* - `serve` in the foreground: systemd is what keeps it outside any session.
|
|
1437
|
+
* - `KillMode=mixed`: a stop sends SIGTERM to the launcher only, which passes one to the host, which
|
|
1438
|
+
* stops its agents in order; the rest of the group is killed only after `TimeoutStopSec`.
|
|
1439
|
+
* - `Restart=on-failure`, but bounded: a host that fails at every start (a port another program
|
|
1440
|
+
* holds, a store from a newer build) is left failed after five tries in five minutes rather than
|
|
1441
|
+
* started forever. A clean stop (`serve --stop`, as an update does) exits 0 and stays stopped.
|
|
1442
|
+
*/
|
|
1443
|
+
export function systemdUnit({ launcher, env }) {
|
|
1444
|
+
return [
|
|
1445
|
+
'# Written by `centralu serve --autostart on`; `centralu serve --autostart off` removes it.',
|
|
1446
|
+
'[Unit]',
|
|
1447
|
+
'Description=Centralu host (centralu serve)',
|
|
1448
|
+
'StartLimitIntervalSec=300',
|
|
1449
|
+
'StartLimitBurst=5',
|
|
1450
|
+
'',
|
|
1451
|
+
'[Service]',
|
|
1452
|
+
'Type=simple',
|
|
1453
|
+
...AUTOSTART_ENV.filter((k) => env[k]).map((k) => `Environment=${systemdQuote(`${k}=${env[k]}`)}`),
|
|
1454
|
+
`ExecStart=${systemdQuote(launcher, { dollars: true })} serve`,
|
|
1455
|
+
'KillMode=mixed',
|
|
1456
|
+
'TimeoutStopSec=30',
|
|
1457
|
+
'Restart=on-failure',
|
|
1458
|
+
'RestartSec=10',
|
|
1459
|
+
'',
|
|
1460
|
+
'[Install]',
|
|
1461
|
+
'WantedBy=default.target',
|
|
1462
|
+
'',
|
|
1463
|
+
].join('\n')
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/** A PowerShell expression for `s`: base64 decoded there, so no quote reaches PowerShell's parser */
|
|
1467
|
+
function psText(s) {
|
|
1468
|
+
return `([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(String(s), 'utf8').toString('base64')}')))`
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/**
|
|
1472
|
+
* The PowerShell the scheduled task runs at sign-in, in a hidden window: `serve` in the foreground,
|
|
1473
|
+
* for as long as it runs. Not `--detach`: a task is not an ssh session, so nothing ends it with one,
|
|
1474
|
+
* and it needs no WMI, which some machines block (decision 3).
|
|
1475
|
+
*
|
|
1476
|
+
* Windows the launcher itself
|
|
1477
|
+
* WSL `wsl.exe -d <distro> -u <user> --exec <launcher> serve`: that `wsl.exe` is a client of
|
|
1478
|
+
* the distro for as long as the host runs, so WSL does not stop it (§10.4)
|
|
1479
|
+
*
|
|
1480
|
+
* The variables of `AUTOSTART_ENV` are set in the task (Windows) or passed through `env` (WSL, where
|
|
1481
|
+
* the Windows side's environment does not reach the distro).
|
|
1482
|
+
*/
|
|
1483
|
+
export function taskActionScript({ how, launcher, env, distro = null, user = null }) {
|
|
1484
|
+
const carried = AUTOSTART_ENV.filter((k) => env[k])
|
|
1485
|
+
if (how === 'wmi') return [...carried.map((k) => `$env:${k} = ${psText(env[k])}`), `& ${psText(launcher)} serve`].join('\n')
|
|
1486
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(distro ?? '')) throw new Error(`not a WSL distro name: ${distro}`)
|
|
1487
|
+
if (!/^[A-Za-z_][A-Za-z0-9_.-]{0,31}$/.test(user ?? '')) throw new Error(`cannot name the user ${user} to wsl.exe`)
|
|
1488
|
+
const args = ['-d', distro, '-u', user, '--cd', '~', '--exec', ...(carried.length ? ['/usr/bin/env', ...carried.map((k) => `${k}=${env[k]}`)] : []), launcher, 'serve']
|
|
1489
|
+
for (const a of args) if (/["\r\n\0]/.test(a)) throw new Error(`cannot pass ${JSON.stringify(a)} on a Windows command line`)
|
|
1490
|
+
return [`$a = @(${args.map(psText).join(', ')})`, "& (Join-Path $env:SystemRoot 'System32\\wsl.exe') @a"].join('\n')
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
const TASK_WORD = 'CENTRALU-AUTOSTART'
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* The PowerShell that registers, removes or reads the scheduled task, printing one `CENTRALU-AUTOSTART`
|
|
1497
|
+
* line. Registered for this user only, at this user's sign-in, run only while they are signed in
|
|
1498
|
+
* (`Interactive`): the kind of task a user may create without an administrator. On a laptop, it runs
|
|
1499
|
+
* on battery too, and with no time limit (the default ends a task after 72 hours).
|
|
1500
|
+
*/
|
|
1501
|
+
export function taskScript(action, { name, run = '' }) {
|
|
1502
|
+
const head = ["$ErrorActionPreference = 'Stop'", '[Console]::OutputEncoding = [Text.Encoding]::UTF8', `$name = ${psText(name)}`]
|
|
1503
|
+
const fail = `} catch { "${TASK_WORD}-ERROR $($_.Exception.Message -replace '\\s+', ' ')" }`
|
|
1504
|
+
const find = "$t = Get-ScheduledTask -TaskName $name -TaskPath '\\' -ErrorAction SilentlyContinue"
|
|
1505
|
+
if (action === 'status') return [...head, 'try {', ` ${find}`, ` if ($t) { "${TASK_WORD} state $($t.State)" } else { '${TASK_WORD} state none' }`, fail].join('\n')
|
|
1506
|
+
if (action === 'off') {
|
|
1507
|
+
return [...head, 'try {', ` ${find}`, " if ($t) { Unregister-ScheduledTask -TaskName $name -TaskPath '\\' -Confirm:$false }", ` '${TASK_WORD} off'`, fail].join('\n')
|
|
1508
|
+
}
|
|
1509
|
+
const encoded = Buffer.from(run, 'utf16le').toString('base64')
|
|
1510
|
+
return [
|
|
1511
|
+
...head,
|
|
1512
|
+
'try {',
|
|
1513
|
+
" $exe = Join-Path $env:SystemRoot 'System32\\WindowsPowerShell\\v1.0\\powershell.exe'",
|
|
1514
|
+
` $arg = '-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encoded}'`,
|
|
1515
|
+
' $user = if ($env:USERDOMAIN) { "$env:USERDOMAIN\\$env:USERNAME" } else { $env:USERNAME }',
|
|
1516
|
+
' $a = New-ScheduledTaskAction -Execute $exe -Argument $arg',
|
|
1517
|
+
' $tr = New-ScheduledTaskTrigger -AtLogOn -User $user',
|
|
1518
|
+
' $p = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited',
|
|
1519
|
+
' $s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew',
|
|
1520
|
+
` 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`,
|
|
1521
|
+
` '${TASK_WORD} on'`,
|
|
1522
|
+
fail,
|
|
1523
|
+
].join('\n')
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/** What a task script printed: `{ ok: true, word }` (`on`, `off`, or the state after `state`) or `{ ok: false, message }` */
|
|
1527
|
+
export function parseTaskAnswer(stdout) {
|
|
1528
|
+
const text = String(stdout ?? '')
|
|
1529
|
+
const e = new RegExp(`${TASK_WORD}-ERROR (.*)`).exec(text)
|
|
1530
|
+
if (e) return { ok: false, message: e[1].trim() }
|
|
1531
|
+
const m = new RegExp(`${TASK_WORD} (?:state )?(\\S+)`).exec(text)
|
|
1532
|
+
if (m) return { ok: true, word: m[1] }
|
|
1533
|
+
return { ok: false, message: 'PowerShell did not answer' }
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/** `{ how, name, linger }` from `AUTOSTART_FILE`, or null */
|
|
1537
|
+
export function readAutostartMark(dataDir) {
|
|
1538
|
+
try {
|
|
1539
|
+
const v = JSON.parse(readFileSync(join(dataDir, AUTOSTART_FILE), 'utf8'))
|
|
1540
|
+
if ((v?.how !== 'systemd' && v?.how !== 'task') || typeof v.name !== 'string') return null
|
|
1541
|
+
return { how: v.how, name: v.name, linger: typeof v.linger === 'boolean' ? v.linger : null }
|
|
1542
|
+
} catch {
|
|
1543
|
+
return null
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
function writeAutostartMark(dataDir, mark) {
|
|
1548
|
+
mkdirSync(dataDir, { recursive: true })
|
|
1549
|
+
const file = join(dataDir, AUTOSTART_FILE)
|
|
1550
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
1551
|
+
writeFileSync(tmp, `${JSON.stringify({ v: 1, ...mark, at: Date.now() })}\n`)
|
|
1552
|
+
renameSync(tmp, file)
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
function clearAutostartMark(dataDir) {
|
|
1556
|
+
rmSync(join(dataDir, AUTOSTART_FILE), { force: true })
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
/**
|
|
1560
|
+
* The connection line's `autostart` field (additive): `{ on, how, linger }`. `how` is what this
|
|
1561
|
+
* machine would use (null where it offers nothing), `on` whether `--autostart on` wrote an entry of
|
|
1562
|
+
* that kind, `linger` (systemd only) whether the user's manager starts at boot. From the mark only:
|
|
1563
|
+
* this runs at every link start.
|
|
1564
|
+
*/
|
|
1565
|
+
export function autostartInfo(dataDir, platform, env, how = autostartHow(platform, env)) {
|
|
1566
|
+
const mark = readAutostartMark(dataDir)
|
|
1567
|
+
const on = how !== null && mark?.how === how
|
|
1568
|
+
return { on, how, linger: on && how === 'systemd' ? mark.linger : null }
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/** The sentence for a user manager that starts only at login (`loginctl enable-linger` refused) */
|
|
1572
|
+
export function lingerMessage(user) {
|
|
1573
|
+
return `systemd starts it when ${user} logs in, not at boot, until an administrator runs \`sudo loginctl enable-linger ${user}\` there`
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/** systemctl --user over ssh needs the user's runtime folder; a session without pam_systemd may not name it */
|
|
1577
|
+
function userManagerEnv(env) {
|
|
1578
|
+
if (env.XDG_RUNTIME_DIR || typeof process.getuid !== 'function') return env
|
|
1579
|
+
const dir = `/run/user/${process.getuid()}`
|
|
1580
|
+
return existsSync(dir) ? { ...env, XDG_RUNTIME_DIR: dir } : env
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
async function systemdAutostart(action, { dataDir, env, home, name, run, user }) {
|
|
1584
|
+
const dir = join(env.XDG_CONFIG_HOME || join(home, '.config'), 'systemd', 'user')
|
|
1585
|
+
const file = join(dir, name)
|
|
1586
|
+
const ctlEnv = userManagerEnv(env)
|
|
1587
|
+
const ctl = (...args) => run('systemctl', ['--user', ...args], ctlEnv)
|
|
1588
|
+
const lingers = async () => {
|
|
1589
|
+
const r = await run('loginctl', ['show-user', user, '--property=Linger', '--value'], ctlEnv)
|
|
1590
|
+
return r.code === 0 ? r.stdout.trim() === 'yes' : null
|
|
1591
|
+
}
|
|
1592
|
+
const base = { ok: true, how: 'systemd' }
|
|
1593
|
+
if (action === 'status') {
|
|
1594
|
+
const on = existsSync(file) && (await ctl('is-enabled', name)).stdout.trim() === 'enabled'
|
|
1595
|
+
const linger = on ? await lingers() : null
|
|
1596
|
+
if (on) writeAutostartMark(dataDir, { how: 'systemd', name, linger })
|
|
1597
|
+
else clearAutostartMark(dataDir)
|
|
1598
|
+
return { ...base, on, linger }
|
|
1599
|
+
}
|
|
1600
|
+
if (action === 'off') {
|
|
1601
|
+
if (existsSync(file)) {
|
|
1602
|
+
await ctl('disable', name)
|
|
1603
|
+
rmSync(file, { force: true })
|
|
1604
|
+
// Left by an `enable` whose `disable` did not reach the manager; harmless, but not ours to leave
|
|
1605
|
+
rmSync(join(dir, 'default.target.wants', name), { force: true })
|
|
1606
|
+
await ctl('daemon-reload')
|
|
1607
|
+
}
|
|
1608
|
+
clearAutostartMark(dataDir)
|
|
1609
|
+
return { ...base, on: false, linger: null }
|
|
1610
|
+
}
|
|
1611
|
+
const launcher = autostartLauncher(dataDir, 'linux')
|
|
1612
|
+
if (!launcher) return { ...base, ok: false, on: false, linger: null, message: `there is no Centralu launcher in ${dataDir} to start at boot` }
|
|
1613
|
+
mkdirSync(dir, { recursive: true })
|
|
1614
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
1615
|
+
writeFileSync(tmp, systemdUnit({ launcher, env }))
|
|
1616
|
+
renameSync(tmp, file)
|
|
1617
|
+
let r = await ctl('daemon-reload')
|
|
1618
|
+
if (r.code === 0) r = await ctl('enable', name)
|
|
1619
|
+
if (r.code !== 0) {
|
|
1620
|
+
rmSync(file, { force: true })
|
|
1621
|
+
await ctl('daemon-reload')
|
|
1622
|
+
clearAutostartMark(dataDir)
|
|
1623
|
+
const why = String(r.stderr).trim().split(/\r?\n/).at(-1) || `exit ${r.code}`
|
|
1624
|
+
return { ...base, ok: false, on: false, linger: null, message: `systemd did not take the unit: ${why}` }
|
|
1625
|
+
}
|
|
1626
|
+
let linger = await lingers()
|
|
1627
|
+
if (linger === false) {
|
|
1628
|
+
await run('loginctl', ['--no-ask-password', 'enable-linger', user], ctlEnv)
|
|
1629
|
+
linger = await lingers()
|
|
1630
|
+
}
|
|
1631
|
+
writeAutostartMark(dataDir, { how: 'systemd', name, linger })
|
|
1632
|
+
return { ...base, on: true, linger, ...(linger === true ? {} : { message: lingerMessage(user) }) }
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
async function taskAutostart(action, { dataDir, env, name, run, user, platform, interop }) {
|
|
1636
|
+
const detach = detachHow(platform, env, interop)
|
|
1637
|
+
const ps = windowsPowershell(detach, env)
|
|
1638
|
+
const base = { ok: true, how: 'task', linger: null }
|
|
1639
|
+
if (!ps) return { ...base, ok: false, on: false, message: 'Windows PowerShell is not reachable from this WSL distro' }
|
|
1640
|
+
const ask = async (script) => {
|
|
1641
|
+
const r = await run(ps, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], env)
|
|
1642
|
+
const a = parseTaskAnswer(r.stdout)
|
|
1643
|
+
return a.ok || r.stdout.trim() || !r.stderr.trim() ? a : { ok: false, message: r.stderr.trim().split(/\r?\n/).at(-1) }
|
|
1644
|
+
}
|
|
1645
|
+
if (action === 'status') {
|
|
1646
|
+
const a = await ask(taskScript('status', { name }))
|
|
1647
|
+
if (!a.ok) return { ...base, ok: false, on: readAutostartMark(dataDir)?.how === 'task', message: `Task Scheduler did not answer: ${a.message}` }
|
|
1648
|
+
const on = a.word !== 'none' && a.word !== 'Disabled'
|
|
1649
|
+
if (on) writeAutostartMark(dataDir, { how: 'task', name, linger: null })
|
|
1650
|
+
else clearAutostartMark(dataDir)
|
|
1651
|
+
return { ...base, on }
|
|
1652
|
+
}
|
|
1653
|
+
if (action === 'off') {
|
|
1654
|
+
const a = await ask(taskScript('off', { name }))
|
|
1655
|
+
if (!a.ok) return { ...base, ok: false, on: true, message: `Task Scheduler did not remove the task "${name}": ${a.message}` }
|
|
1656
|
+
clearAutostartMark(dataDir)
|
|
1657
|
+
return { ...base, on: false }
|
|
1658
|
+
}
|
|
1659
|
+
const launcher = autostartLauncher(dataDir, platform)
|
|
1660
|
+
if (!launcher) return { ...base, ok: false, on: false, message: `there is no Centralu launcher in ${dataDir} to start at sign-in` }
|
|
1661
|
+
const runScript = taskActionScript({ how: detach, launcher, env, distro: env.WSL_DISTRO_NAME ?? null, user })
|
|
1662
|
+
const a = await ask(taskScript('on', { name, run: runScript }))
|
|
1663
|
+
if (!a.ok) return { ...base, ok: false, on: false, message: `Task Scheduler did not take the task: ${a.message}` }
|
|
1664
|
+
writeAutostartMark(dataDir, { how: 'task', name, linger: null })
|
|
1665
|
+
return { ...base, on: true }
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/**
|
|
1669
|
+
* `centralu serve --autostart on|off|status` (docs/plans/remote-hub.md §10.4, owner decision 4: per
|
|
1670
|
+
* machine, off by default). The hub runs it over ssh for the Settings → Machines toggle, and its
|
|
1671
|
+
* uninstall runs `off`. Turning it on or off never starts or stops a host: one runs already when the
|
|
1672
|
+
* hub asks (the link started it), and the entry is for the next boot.
|
|
1673
|
+
*
|
|
1674
|
+
* stdout: one line, `{ v: 1, autostart: { ok, on, how, linger, message? } }`; `how` null where this
|
|
1675
|
+
* machine offers nothing (`autostartHow`). `run` (a command runner) and `how` are parameters so
|
|
1676
|
+
* tests never reach the real systemd or Task Scheduler.
|
|
1677
|
+
*/
|
|
1678
|
+
export async function runAutostart({
|
|
1679
|
+
env,
|
|
1680
|
+
home,
|
|
1681
|
+
action,
|
|
1682
|
+
cliPath,
|
|
1683
|
+
platform = process.platform,
|
|
1684
|
+
interop = wslInterop,
|
|
1685
|
+
how = autostartHow(platform, env, { interop }),
|
|
1686
|
+
run = runCaptured,
|
|
1687
|
+
user = userInfo().username,
|
|
1688
|
+
write = (line) => process.stdout.write(line),
|
|
1689
|
+
}) {
|
|
1690
|
+
const dataDir = serveDataDir(env, home)
|
|
1691
|
+
// The entry of an npm install runs the launcher serve keeps; make sure it is there and current
|
|
1692
|
+
keepLauncher(dataDir, cliPath, env)
|
|
1693
|
+
const report = (a) => {
|
|
1694
|
+
if (a.message) say(a.message)
|
|
1695
|
+
write(`${JSON.stringify({ v: 1, autostart: a })}\n`)
|
|
1696
|
+
return a.ok ? 0 : 1
|
|
1697
|
+
}
|
|
1698
|
+
if (how === null) {
|
|
1699
|
+
clearAutostartMark(dataDir)
|
|
1700
|
+
return report({ ok: action !== 'on', on: false, how: null, linger: null, ...(action === 'off' ? {} : { message: autostartUnsupported(platform, env) }) })
|
|
1701
|
+
}
|
|
1702
|
+
try {
|
|
1703
|
+
const name = autostartName(how, { dataDir, home, env, platform })
|
|
1704
|
+
const opts = { dataDir, env, home, name, run, user, platform, interop }
|
|
1705
|
+
return report(how === 'systemd' ? await systemdAutostart(action, opts) : await taskAutostart(action, opts))
|
|
1706
|
+
} catch (e) {
|
|
1707
|
+
return report({ ok: false, on: readAutostartMark(dataDir)?.how === how, how, linger: null, message: e?.message ?? String(e) })
|
|
1708
|
+
}
|
|
1709
|
+
}
|
package/bin/swap.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replacing an installed copy of the app without a moment where there is none.
|
|
3
|
+
*
|
|
4
|
+
* Lives apart from `centralu.mjs` for the same reason `platform.mjs` does: the launcher runs a
|
|
5
|
+
* command at import time, so a test cannot import it. The paths come in as arguments, so
|
|
6
|
+
* `tooling/launcher-swap.test.ts` runs this against temporary folders instead of `/Applications`.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, renameSync, rmSync } from 'node:fs'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Puts a new copy at `target`: `fill(staging)` writes it beside the old one, the old one is
|
|
12
|
+
* renamed aside, the new one is renamed in, and only then is the old one deleted.
|
|
13
|
+
*
|
|
14
|
+
* The old copy is never deleted before the new one is complete. Deleting first and copying
|
|
15
|
+
* after left a person whose copy failed (a full disk, a permission, an interrupted `ditto`)
|
|
16
|
+
* with no app at all, from a command they ran to update it. A copy that fails here leaves the
|
|
17
|
+
* old install untouched, and a rename that fails puts the old one back.
|
|
18
|
+
*
|
|
19
|
+
* Returns whether something was replaced, and where the old copy was left if it could not be
|
|
20
|
+
* deleted (null when it was).
|
|
21
|
+
*/
|
|
22
|
+
export function swapInto(target, fill, now = Date.now()) {
|
|
23
|
+
const staging = `${target}.new`
|
|
24
|
+
const aside = `${target}.old-${now}`
|
|
25
|
+
rmSync(staging, { recursive: true, force: true })
|
|
26
|
+
try {
|
|
27
|
+
fill(staging)
|
|
28
|
+
} catch (e) {
|
|
29
|
+
rmSync(staging, { recursive: true, force: true })
|
|
30
|
+
throw e
|
|
31
|
+
}
|
|
32
|
+
const replaced = existsSync(target)
|
|
33
|
+
if (replaced) renameSync(target, aside)
|
|
34
|
+
try {
|
|
35
|
+
renameSync(staging, target)
|
|
36
|
+
} catch (e) {
|
|
37
|
+
if (replaced) renameSync(aside, target)
|
|
38
|
+
rmSync(staging, { recursive: true, force: true })
|
|
39
|
+
throw e
|
|
40
|
+
}
|
|
41
|
+
if (!replaced) return { replaced, leftAt: null }
|
|
42
|
+
try {
|
|
43
|
+
rmSync(aside, { recursive: true, force: true })
|
|
44
|
+
return { replaced, leftAt: null }
|
|
45
|
+
} catch {
|
|
46
|
+
return { replaced, leftAt: aside }
|
|
47
|
+
}
|
|
48
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "centralu",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.15",
|
|
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",
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"win32"
|
|
38
38
|
],
|
|
39
39
|
"optionalDependencies": {
|
|
40
|
-
"@centralu/darwin-arm64": "0.1.0-beta.
|
|
41
|
-
"@centralu/linux-arm64": "0.1.0-beta.
|
|
42
|
-
"@centralu/linux-x64": "0.1.0-beta.
|
|
43
|
-
"@centralu/win32-x64": "0.1.0-beta.
|
|
40
|
+
"@centralu/darwin-arm64": "0.1.0-beta.15",
|
|
41
|
+
"@centralu/linux-arm64": "0.1.0-beta.15",
|
|
42
|
+
"@centralu/linux-x64": "0.1.0-beta.15",
|
|
43
|
+
"@centralu/win32-x64": "0.1.0-beta.15"
|
|
44
44
|
}
|
|
45
45
|
}
|