centralu 0.1.0-beta.22 → 0.1.0-beta.24

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
@@ -23,9 +23,10 @@
23
23
  * being first exercised on someone's laptop.
24
24
  */
25
25
  import { execFileSync, spawn } from 'node:child_process'
26
- import { copyDiffers, isNewer } from './semver.mjs'
26
+ import { copyDiffers, isNewer, keepsInstalledCopy } from './semver.mjs'
27
27
  import {
28
28
  asideCopies,
29
+ bundleVersionCommand,
29
30
  busyMessage,
30
31
  earlyExitMessage,
31
32
  executableIn,
@@ -55,6 +56,7 @@ import {
55
56
  SERVE_HELP,
56
57
  } from './serve.mjs'
57
58
  import { swapInto } from './swap.mjs'
59
+ import { belowFloor, floorOf, oldNodeSentence } from './node-floor.mjs'
58
60
  import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
59
61
  import { createRequire } from 'node:module'
60
62
  import { homedir } from 'node:os'
@@ -64,8 +66,15 @@ const require = createRequire(import.meta.url)
64
66
  const pkg = require('../package.json')
65
67
 
66
68
  const APP_NAME = 'Centralu'
69
+ /** The oldest Node the host runs on: this package's own `engines.node` (node-floor.mjs) */
70
+ const NODE_FLOOR = floorOf(pkg.engines.node)
67
71
  const BUNDLE = `${APP_NAME}.app`
68
- const INSTALLED = `/Applications/${BUNDLE}`
72
+ /*
73
+ * `CENTRALU_INSTALLED_COPY` points `install` and the copy checks at another bundle path. It exists
74
+ * so the launcher's own tests can run `centralu install` against a temporary folder and never
75
+ * against the real /Applications (tooling/launcher-install.test.ts).
76
+ */
77
+ const INSTALLED = process.env.CENTRALU_INSTALLED_COPY || `/Applications/${BUNDLE}`
69
78
 
70
79
  const PLATFORM = process.platform
71
80
  const HOME = homedir()
@@ -275,8 +284,22 @@ function queryRegistry(key) {
275
284
  * **Never done quietly through postinstall.** Writing silently into someone's `/Applications`
276
285
  * erodes trust, and pnpm blocks postinstall by default anyway. This is only run when the
277
286
  * person explicitly asks for it.
287
+ *
288
+ * **Never over a newer local build** (#645), unless `--force`. The in-app update runs this after
289
+ * `npm i -g` with the same rule (`keepsInstalledCopy`): a maintainer's local build
290
+ * (`0.2.0-beta.21.local.1`) waiting for "Apply now" would otherwise be replaced by npm's beta.21.
291
+ * Any released version is replaced, even a higher one: npm is the source of truth for releases,
292
+ * and a release pulled from `latest` must not survive here. The check comes before finding the app
293
+ * package, since keeping the copy needs nothing from it. `--force` replaces a local build on purpose.
278
294
  */
279
- function install() {
295
+ function install(force) {
296
+ const copy = installedCopyVersion()
297
+ if (!force && keepsInstalledCopy(pkg.version, copy)) {
298
+ const where = PLATFORM === 'win32' ? WIN.dir : INSTALLED
299
+ console.log(`${where} holds the local build ${copy}, newer than this package (${pkg.version}), and was left as it is.`)
300
+ console.log('To replace it anyway: centralu install --force')
301
+ return
302
+ }
280
303
  const app = requireApp()
281
304
  if (PLATFORM === 'win32') return installWindows(app)
282
305
  if (PLATFORM !== 'darwin') return installDesktopEntry(app)
@@ -498,6 +521,7 @@ async function update() {
498
521
 
499
522
  /**
500
523
  * The version of the copy placed in /Applications. null if there is nothing to compare.
524
+ * (`bundleVersionCommand`: the in-app update reads it with the same command.)
501
525
  *
502
526
  * `defaults` is used because there is no guarantee Info.plist stays XML — Tauri just happens
503
527
  * to write it as XML today, and if it ever switches to a binary plist, anything reading it
@@ -517,7 +541,8 @@ function installedCopyVersion() {
517
541
  }
518
542
  if (PLATFORM !== 'darwin' || !existsSync(INSTALLED)) return null
519
543
  try {
520
- const out = execFileSync('/usr/bin/defaults', ['read', join(INSTALLED, 'Contents/Info.plist'), 'CFBundleShortVersionString'], {
544
+ const read = bundleVersionCommand(INSTALLED)
545
+ const out = execFileSync(read.command, read.args, {
521
546
  encoding: 'utf8',
522
547
  stdio: ['ignore', 'pipe', 'ignore'],
523
548
  })
@@ -561,19 +586,20 @@ const HELP = `${APP_NAME} ${pkg.version}
561
586
 
562
587
  centralu Launches the app
563
588
  centralu install Registers it in the app list (macOS: /Applications, Linux: a menu entry,
564
- Windows: %LOCALAPPDATA%\\Programs\\Centralu and a Start menu shortcut)
589
+ Windows: %LOCALAPPDATA%\\Programs\\Centralu and a Start menu shortcut).
590
+ A newer local build there is kept; --force replaces it anyway
565
591
  centralu uninstall Removes that registration (conversation history is kept)
566
592
  centralu update Upgrades if a new version is available
567
593
  centralu serve Runs the host alone on this machine, for the app on another computer
568
594
  to reach over SSH (\`centralu serve --help\`)
569
595
  centralu --version Prints the version
570
596
 
571
- Requires: Node 22+, and the claude or codex CLI (the app's first screen reports the status)`
597
+ Requires: Node ${NODE_FLOOR}+, and the claude or codex CLI (the app's first screen reports the status)`
572
598
 
573
599
  const [cmd, ...rest] = process.argv.slice(2)
574
600
  switch (cmd) {
575
601
  case 'install':
576
- install()
602
+ install(rest.includes('--force'))
577
603
  break
578
604
  case 'uninstall':
579
605
  uninstall()
@@ -598,6 +624,26 @@ switch (cmd) {
598
624
  process.exitCode = rotateToken({ env: process.env, home: HOME })
599
625
  break
600
626
  }
627
+ /*
628
+ * Before anything spawns a host: under a Node below the floor the host crashes on its first
629
+ * database call, with nothing in its log (issue #699). `--detach` also answers in the line the
630
+ * hub reads, so it can offer its managed install instead of quoting a crash. `--autostart on`
631
+ * writes a boot entry that would start that host. `--connection`, `--stop`, `--autostart
632
+ * off|status` and `--rotate-token` start no host and keep working, so the hub can still ask this
633
+ * machine what Node it has.
634
+ */
635
+ const startsHost = args.mode === 'serve' || args.mode === 'detach' || (args.mode === 'autostart' && args.autostart === 'on')
636
+ if (startsHost && belowFloor(process.versions.node, NODE_FLOOR)) {
637
+ const message = oldNodeSentence(process.versions.node, NODE_FLOOR)
638
+ console.error(`centralu serve: ${message}`)
639
+ if (args.mode === 'detach') {
640
+ const line = { v: 1, detach: { ok: false, how: null, reason: 'node_too_old', message, node: process.versions.node, floor: NODE_FLOOR } }
641
+ process.stdout.write(`${JSON.stringify(line)}\n`)
642
+ }
643
+ // Not process.exit: the line above is on a pipe, and exiting at once may cut it
644
+ process.exitCode = 1
645
+ break
646
+ }
601
647
  // A launcher `--detach` started: what it carried (the data folder, a host entry) applies before
602
648
  // anything below reads the environment
603
649
  const spec = args.child !== undefined ? decodeChildSpec(args.child) : null
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The oldest Node the bundled host runs on, and what the launcher says when it is run on an older one.
3
+ *
4
+ * **The floor is `engines.node` of this package, read here and nowhere restated.** The host's native
5
+ * database module (better-sqlite3 13) declares Node-API 10, which Node has from 22.14.0. Under an
6
+ * older 22.x (and under 20) it loads, then crashes on its first use with an access violation
7
+ * (`0xC0000005`, exit 3221225477, on Windows) or SIGSEGV (measured on 22.12.0, Windows and macOS,
8
+ * issue #699), before the host logs a line. The hub reads the same value from its copy of this
9
+ * package's `package.json` (`packages/agent-host/src/links/node-floor.ts`), and
10
+ * `tooling/engines.test.ts` keeps the other declarations equal to it.
11
+ *
12
+ * Lives in its own file so it can be tested (tooling/launcher-node-floor.test.ts): the launcher
13
+ * itself runs commands at import time. Plain JavaScript on Node's own modules and `semver.mjs`.
14
+ */
15
+ import { isNewer } from './semver.mjs'
16
+
17
+ /** `>=22.14.0` or `>=22.14` → `22.14.0`; throws on any other shape, which `engines` must not take */
18
+ export function floorOf(range) {
19
+ const m = /^>=\s*(\d+)\.(\d+)(?:\.(\d+))?$/.exec(String(range).trim())
20
+ if (!m) throw new Error(`engines.node must be a plain floor like ">=22.14.0", not ${JSON.stringify(range)}`)
21
+ return `${m[1]}.${m[2]}.${m[3] ?? '0'}`
22
+ }
23
+
24
+ /** Whether `found` (`22.12.0` or `v22.12.0`) is older than `floor`; a version that does not parse is let through */
25
+ export function belowFloor(found, floor) {
26
+ const v = String(found).trim().replace(/^v/i, '')
27
+ if (!/^\d+\.\d+\.\d+/.test(v)) return false
28
+ return isNewer(floor, v)
29
+ }
30
+
31
+ /**
32
+ * The one sentence for a launcher that will not start the host on this Node: the Node it found, the
33
+ * floor, and the two ways out. It names no command, since it is also what the hub reads out of the
34
+ * `--detach` answer.
35
+ */
36
+ export function oldNodeSentence(found, floor) {
37
+ const v = String(found).replace(/^v/i, '')
38
+ return (
39
+ `centralu serve needs Node ${floor} or newer and this is Node ${v}: update Node on this machine, ` +
40
+ 'or link this machine from a Centralu window on another computer, which installs a Node of its own there.'
41
+ )
42
+ }
package/bin/platform.mjs CHANGED
@@ -357,3 +357,15 @@ export function busyMessage(path) {
357
357
  export function isBusyError(e) {
358
358
  return e?.code === 'EBUSY' || e?.code === 'EPERM' || e?.code === 'EACCES' || e?.code === 'ENOTEMPTY'
359
359
  }
360
+
361
+ /**
362
+ * The command that prints the version a macOS bundle carries (`CFBundleShortVersionString`).
363
+ *
364
+ * `plutil` reads XML and binary plists alike, so nothing breaks if Tauri ever writes a binary one.
365
+ * The in-app update runs the same command (`installedCopyVersionSource` in
366
+ * `packages/agent-host/src/os.ts`, pinned to this by `updates.test.ts`), so the host and the
367
+ * launcher can never disagree about which version a copy is (#645).
368
+ */
369
+ export function bundleVersionCommand(bundle) {
370
+ return { command: '/usr/bin/plutil', args: ['-extract', 'CFBundleShortVersionString', 'raw', '-o', '-', posix.join(bundle, 'Contents', 'Info.plist')] }
371
+ }
package/bin/semver.mjs CHANGED
@@ -82,3 +82,28 @@ function split(raw) {
82
82
  const pre = dash === -1 ? null : v.slice(dash + 1)
83
83
  return [core, pre]
84
84
  }
85
+
86
+ const LOCAL_BUILD = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*\.local\.[1-9]\d*$/
87
+
88
+ /** A maintainer's local build: `<base>.local.N`, strict SemVer, with a prerelease base and a positive N (`scripts/local-manifest.mjs`). */
89
+ export function isLocalBuildVersion(v) {
90
+ return typeof v === 'string' && v.length <= 64 && LOCAL_BUILD.test(v)
91
+ }
92
+
93
+ /**
94
+ * Whether the copy `centralu install` made is kept rather than replaced by `target` (#645).
95
+ *
96
+ * npm is the source of truth for releases: any released version in the copy, even a higher one, is
97
+ * replaced by the target, so a release pulled from `latest` (a dist-tag rollback) does not survive in
98
+ * /Applications. The one exception is a maintainer's local build (`<base>.local.N`, `pnpm
99
+ * local-update`, #610/#613) newer than the target: it waits there for "Apply now", and putting the
100
+ * release it was cut from over it would undo it. The copy's version must be well-formed SemVer in
101
+ * that exact shape; anything unreadable or malformed (`garbage`, `$(VERSION)`, an empty file) is
102
+ * replaced, so a corrupt plist or version file can never block every later update.
103
+ *
104
+ * `centralu install` applies this unless `--force` is given; the in-app update applies the mirror in
105
+ * `packages/protocol/src/semver.ts` (pinned answer to answer by `tooling/launcher-semver.test.ts`).
106
+ */
107
+ export function keepsInstalledCopy(target, copyVersion) {
108
+ return isLocalBuildVersion(copyVersion) && isNewer(copyVersion, target)
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "centralu",
3
- "version": "0.1.0-beta.22",
3
+ "version": "0.1.0-beta.24",
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",
@@ -21,6 +21,7 @@
21
21
  "windows",
22
22
  "desktop"
23
23
  ],
24
+ "protocolVersion": 1,
24
25
  "type": "module",
25
26
  "bin": {
26
27
  "centralu": "bin/centralu.mjs"
@@ -29,7 +30,7 @@
29
30
  "bin"
30
31
  ],
31
32
  "engines": {
32
- "node": ">=22"
33
+ "node": ">=22.14.0"
33
34
  },
34
35
  "os": [
35
36
  "darwin",
@@ -37,9 +38,9 @@
37
38
  "win32"
38
39
  ],
39
40
  "optionalDependencies": {
40
- "@centralu/darwin-arm64": "0.1.0-beta.22",
41
- "@centralu/linux-arm64": "0.1.0-beta.22",
42
- "@centralu/linux-x64": "0.1.0-beta.22",
43
- "@centralu/win32-x64": "0.1.0-beta.22"
41
+ "@centralu/darwin-arm64": "0.1.0-beta.24",
42
+ "@centralu/linux-arm64": "0.1.0-beta.24",
43
+ "@centralu/linux-x64": "0.1.0-beta.24",
44
+ "@centralu/win32-x64": "0.1.0-beta.24"
44
45
  }
45
46
  }