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 +30 -20
- package/bin/platform.mjs +40 -0
- package/bin/semver.mjs +9 -1
- package/bin/serve.mjs +399 -10
- 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
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
|
|
151
|
-
* given. `--port` goes with `serve` and `--detach` only: `--connection` has to report the
|
|
152
|
-
* `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.
|
|
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 (
|
|
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.
|
|
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.
|
|
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.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
|
}
|