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 CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  busyMessage,
30
30
  earlyExitMessage,
31
31
  executableIn,
32
- hostDirIn,
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
- let root
134
- try {
135
- root = dirname(require.resolve(`${TARGET.pkg}/package.json`))
136
- } catch {
137
- console.error(explainMissing())
138
- process.exit(1)
139
- }
140
- const entry = join(hostDirIn(PLATFORM, join(root, TARGET.artifact)), 'main.mjs')
141
- if (!existsSync(entry)) {
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 ${TARGET.pkg} (looked for ${entry}).\n` +
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
- return entry
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
- console.log(`Replacing the existing ${INSTALLED} with the new version.`)
277
- rmSync(INSTALLED, { recursive: true, force: true })
278
- }
279
- // ditto, not cp — carries over the bundle's permissions and extended attributes unchanged
280
- // (so the signature does not break).
281
- execFileSync('/usr/bin/ditto', [app, INSTALLED], { stdio: 'inherit' })
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
- function split(v) {
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 when not
87
- * given. `--port` goes with `serve` and `--detach` only: `--connection` has to report the port
88
- * `serve` uses, not one the caller names, and `--stop` stops the one that runs.
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 (Object.hasOwn(MODE_FLAGS, a)) {
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.13",
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.13",
41
- "@centralu/linux-arm64": "0.1.0-beta.13",
42
- "@centralu/linux-x64": "0.1.0-beta.13",
43
- "@centralu/win32-x64": "0.1.0-beta.13"
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
  }