centralu 0.1.0-beta.14 → 0.1.0-beta.16

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
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
  /**
@@ -122,6 +122,11 @@ export const SERVE_HELP = `centralu serve: run the Centralu host on this machine
122
122
  centralu serve --stop Ask the running host to stop, and wait until it has
123
123
  centralu serve --connection Print how to reach it as one JSON line, then exit
124
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
125
130
 
126
131
  The data folder is ~/.centralu (CC_DATA_DIR overrides it). The app on another computer reaches
127
132
  this host through an SSH local forward, and starts it with --detach when it is not running.
@@ -134,7 +139,10 @@ from an install the app made over SSH, which has a launcher of its own.`
134
139
 
135
140
  /** The flags that each make serve a different command */
136
141
  const MODE_FLAGS = { '--connection': 'connection', '--rotate-token': 'rotate', '--detach': 'detach', '--stop': 'stop' }
137
- 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']
138
146
 
139
147
  /**
140
148
  * The argument a detached launcher is started with: what it needs from the `--detach` that started
@@ -144,21 +152,29 @@ const FLAG_OF = Object.fromEntries(Object.entries(MODE_FLAGS).map(([f, m]) => [m
144
152
  const CHILD_FLAG = '--detached-child='
145
153
 
146
154
  /**
147
- * What the arguments after `serve` ask for: `{ mode, port }` (plus `child` for a detached launcher)
148
- * or `{ error }`.
155
+ * What the arguments after `serve` ask for: `{ mode, port }` (plus `child` for a detached launcher,
156
+ * `autostart` for `--autostart`) or `{ error }`.
149
157
  *
150
- * `mode` is `serve`, `detach`, `stop`, `connection`, `rotate` or `help`; `port` is null when not
151
- * given. `--port` goes with `serve` and `--detach` only: `--connection` has to report the port
152
- * `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.
153
162
  */
154
163
  export function parseServeArgs(argv) {
155
164
  let mode = 'serve'
156
165
  let port = null
157
166
  let help = false
158
167
  let child = null
168
+ let autostart = null
159
169
  for (let i = 0; i < argv.length; i++) {
160
170
  const a = argv[i]
161
- 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)) {
162
178
  const m = MODE_FLAGS[a]
163
179
  if (mode !== 'serve' && mode !== m) return { error: `${FLAG_OF[mode]} and ${a} are separate commands; pass one` }
164
180
  mode = m
@@ -180,6 +196,10 @@ export function parseServeArgs(argv) {
180
196
  return { error: '--port goes with `centralu serve`; `--connection` reports the port serve listens on' }
181
197
  }
182
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
+ }
183
203
  return { mode, port }
184
204
  }
185
205
 
@@ -378,7 +398,7 @@ export function protocolVersionOf(entry) {
378
398
  * `install` (additive, the line stays `v: 1`) says what runs this command (`installInfo`); `detach`
379
399
  * is added by `--detach` only.
380
400
  */
381
- export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, detach }) {
401
+ export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, autostart, detach }) {
382
402
  return JSON.stringify({
383
403
  v: 1,
384
404
  port,
@@ -388,6 +408,7 @@ export function connectionLine({ port, token, version, protocolVersion, dataDir,
388
408
  dataDir,
389
409
  hostRunning,
390
410
  ...(install ? { install } : {}),
411
+ ...(autostart ? { autostart } : {}),
391
412
  ...(detach ? { detach } : {}),
392
413
  })
393
414
  }
@@ -678,6 +699,7 @@ async function describe({ env, dataDir, state, entry, version }) {
678
699
  dataDir,
679
700
  hostRunning: probe.running,
680
701
  install: installInfo(dataDir, env),
702
+ autostart: autostartInfo(dataDir, process.platform, env),
681
703
  }
682
704
  }
683
705
 
@@ -1318,3 +1340,370 @@ export async function runStop({
1318
1340
  if (!stopped) return report({ ok: false, wasRunning: true, how, message: `the host on 127.0.0.1:${port} is still running${note ? ` (${note})` : ''}` })
1319
1341
  return report({ ok: true, wasRunning: true, how, ...(note ? { message: note } : {}) })
1320
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.14",
3
+ "version": "0.1.0-beta.16",
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.14",
41
- "@centralu/linux-arm64": "0.1.0-beta.14",
42
- "@centralu/linux-x64": "0.1.0-beta.14",
43
- "@centralu/win32-x64": "0.1.0-beta.14"
40
+ "@centralu/darwin-arm64": "0.1.0-beta.16",
41
+ "@centralu/linux-arm64": "0.1.0-beta.16",
42
+ "@centralu/linux-x64": "0.1.0-beta.16",
43
+ "@centralu/win32-x64": "0.1.0-beta.16"
44
44
  }
45
45
  }