centralu 0.1.0-beta.3 → 0.1.0-beta.31
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 +491 -102
- package/bin/node-floor.mjs +42 -0
- package/bin/platform.mjs +380 -0
- package/bin/semver.mjs +55 -3
- package/bin/serve-args.mjs +116 -0
- package/bin/serve-autostart.mjs +489 -0
- package/bin/serve-detach.mjs +289 -0
- package/bin/serve-files.mjs +299 -0
- package/bin/serve-handover.mjs +152 -0
- package/bin/serve-host.mjs +395 -0
- package/bin/serve-install.mjs +204 -0
- package/bin/serve-port.mjs +79 -0
- package/bin/serve-run.mjs +197 -0
- package/bin/serve-util.mjs +127 -0
- package/bin/serve-window.mjs +112 -0
- package/bin/serve-windows.mjs +171 -0
- package/bin/serve.mjs +37 -0
- package/bin/swap.mjs +48 -0
- package/package.json +10 -6
package/bin/centralu.mjs
CHANGED
|
@@ -1,23 +1,64 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Centralu
|
|
3
|
+
* The Centralu launcher.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* **Why this ships through npm:** macOS does not inspect the app and decide it is dangerous —
|
|
6
|
+
* it looks at the `com.apple.quarantine` flag attached to the file and checks the developer's
|
|
7
|
+
* identity from that. That flag is attached **by the program that downloaded it** — a browser
|
|
8
|
+
* attaches it, npm does not. So an npm install just opens with no warning even with an ad hoc
|
|
9
|
+
* signature (docs/plans/beta-release-checklist.md §2).
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* The app itself lives inside an architecture-specific package (`@centralu/darwin-arm64`). This
|
|
12
|
+
* package is a thin shell that finds it and launches it — the same structure esbuild and swc
|
|
13
|
+
* use.
|
|
12
14
|
*
|
|
13
15
|
* Linux is served from the same shim (issue #14). The two platforms disagree about what
|
|
14
16
|
* "the app" even is — macOS hands a `.app` directory to LaunchServices, Linux runs a
|
|
15
17
|
* self-contained AppImage itself — so that difference lives in one table (`TARGETS`)
|
|
16
18
|
* instead of being rediscovered in every function.
|
|
19
|
+
*
|
|
20
|
+
* Windows joined in W3 of #14. Its decisions — the exe inside the folder, a detached start,
|
|
21
|
+
* where `install` copies to, the WebView2 check — live in `platform.mjs` as functions of the
|
|
22
|
+
* platform, so they are tested on macOS CI (tooling/launcher-platform.test.ts) instead of
|
|
23
|
+
* being first exercised on someone's laptop.
|
|
17
24
|
*/
|
|
18
25
|
import { execFileSync, spawn } from 'node:child_process'
|
|
19
|
-
import { isNewer } from './semver.mjs'
|
|
20
|
-
import {
|
|
26
|
+
import { copyDiffers, isNewer, keepsInstalledCopy } from './semver.mjs'
|
|
27
|
+
import {
|
|
28
|
+
asideCopies,
|
|
29
|
+
bundleVersionCommand,
|
|
30
|
+
busyMessage,
|
|
31
|
+
earlyExitMessage,
|
|
32
|
+
executableIn,
|
|
33
|
+
findHostEntry,
|
|
34
|
+
installedPaths,
|
|
35
|
+
isBusyError,
|
|
36
|
+
launchPlan,
|
|
37
|
+
npmCommand,
|
|
38
|
+
regExe,
|
|
39
|
+
shortcutCommand,
|
|
40
|
+
targetFor,
|
|
41
|
+
TARGETS,
|
|
42
|
+
webview2MissingMessage,
|
|
43
|
+
webview2Status,
|
|
44
|
+
windowsInstall,
|
|
45
|
+
} from './platform.mjs'
|
|
46
|
+
import {
|
|
47
|
+
decodeChildSpec,
|
|
48
|
+
parseServeArgs,
|
|
49
|
+
printConnection,
|
|
50
|
+
rotateToken,
|
|
51
|
+
runAutostart,
|
|
52
|
+
runDetach,
|
|
53
|
+
runDetachedChild,
|
|
54
|
+
runServe,
|
|
55
|
+
runStop,
|
|
56
|
+
runWindow,
|
|
57
|
+
SERVE_HELP,
|
|
58
|
+
} from './serve.mjs'
|
|
59
|
+
import { swapInto } from './swap.mjs'
|
|
60
|
+
import { belowFloor, floorOf, oldNodeSentence } from './node-floor.mjs'
|
|
61
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
21
62
|
import { createRequire } from 'node:module'
|
|
22
63
|
import { homedir } from 'node:os'
|
|
23
64
|
import { dirname, join } from 'node:path'
|
|
@@ -26,72 +67,105 @@ const require = createRequire(import.meta.url)
|
|
|
26
67
|
const pkg = require('../package.json')
|
|
27
68
|
|
|
28
69
|
const APP_NAME = 'Centralu'
|
|
70
|
+
/** The oldest Node the host runs on: this package's own `engines.node` (node-floor.mjs) */
|
|
71
|
+
const NODE_FLOOR = floorOf(pkg.engines.node)
|
|
29
72
|
const BUNDLE = `${APP_NAME}.app`
|
|
30
|
-
|
|
73
|
+
/*
|
|
74
|
+
* `CENTRALU_INSTALLED_COPY` points `install` and the copy checks at another bundle path. It exists
|
|
75
|
+
* so the launcher's own tests can run `centralu install` against a temporary folder and never
|
|
76
|
+
* against the real /Applications (tooling/launcher-install.test.ts).
|
|
77
|
+
*/
|
|
78
|
+
const INSTALLED = process.env.CENTRALU_INSTALLED_COPY || `/Applications/${BUNDLE}`
|
|
79
|
+
|
|
80
|
+
const PLATFORM = process.platform
|
|
81
|
+
const HOME = homedir()
|
|
31
82
|
|
|
32
83
|
/** Where a menu entry for the app goes on freedesktop desktops */
|
|
33
|
-
const DESKTOP_ENTRY = join(
|
|
84
|
+
const DESKTOP_ENTRY = join(HOME, '.local/share/applications/centralu.desktop')
|
|
34
85
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
*
|
|
38
|
-
* `artifact` must match `scripts/release-npm.mts` exactly — that script renames what
|
|
39
|
-
* Tauri produced (`Centralu_0.1.0-beta.2_amd64.AppImage`) to a fixed name precisely so
|
|
40
|
-
* this file does not have to know version or architecture. If the two ever disagree the
|
|
41
|
-
* symptom is "installed fine, does nothing", so keep them in one place mentally.
|
|
42
|
-
*
|
|
43
|
-
* Only combinations that are actually published belong here. Listing a package that was
|
|
44
|
-
* never released would turn "not supported yet" into "your install is broken", which
|
|
45
|
-
* sends the user off to reinstall something that can never appear.
|
|
46
|
-
*/
|
|
47
|
-
const TARGETS = {
|
|
48
|
-
'darwin-arm64': { pkg: 'centralu-darwin-arm64', artifact: BUNDLE },
|
|
49
|
-
'linux-arm64': { pkg: 'centralu-linux-arm64', artifact: `${APP_NAME}.AppImage` },
|
|
50
|
-
'linux-x64': { pkg: 'centralu-linux-x64', artifact: `${APP_NAME}.AppImage` },
|
|
51
|
-
}
|
|
86
|
+
/** Where `centralu install` copies the app on Windows (see `windowsInstall`) */
|
|
87
|
+
const WIN = PLATFORM === 'win32' ? windowsInstall(process.env, HOME) : null
|
|
52
88
|
|
|
53
|
-
|
|
89
|
+
/** The arch package for this machine (the table lives in platform.mjs) */
|
|
90
|
+
const TARGET = targetFor(PLATFORM, process.arch)
|
|
54
91
|
|
|
55
|
-
/**
|
|
92
|
+
/** The app inside the architecture-specific package. null if it cannot be found. */
|
|
56
93
|
function bundledApp() {
|
|
57
94
|
if (!TARGET) return null
|
|
58
95
|
let root
|
|
59
96
|
try {
|
|
60
97
|
root = dirname(require.resolve(`${TARGET.pkg}/package.json`))
|
|
61
98
|
} catch {
|
|
62
|
-
return null //
|
|
99
|
+
return null // The package for this architecture is not installed — the reason is explained below.
|
|
63
100
|
}
|
|
64
101
|
const app = join(root, TARGET.artifact)
|
|
65
|
-
return existsSync(app) ? app : null
|
|
102
|
+
return existsSync(executableIn(PLATFORM, app)) ? app : null
|
|
66
103
|
}
|
|
67
104
|
|
|
68
105
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
106
|
+
* States **specifically** why this machine cannot run it right now.
|
|
107
|
+
* If all that shows is "not installed", there is nothing the person can act on.
|
|
71
108
|
*/
|
|
72
109
|
function explainMissing() {
|
|
73
110
|
if (!TARGET) {
|
|
74
111
|
// Kept as its own case: "not published for your machine" and "not published for
|
|
75
112
|
// Intel Macs, and here is the reason" send the user to different places.
|
|
76
|
-
if (
|
|
113
|
+
if (PLATFORM === 'darwin') {
|
|
77
114
|
return (
|
|
78
|
-
`${APP_NAME}
|
|
79
|
-
'Intel
|
|
115
|
+
`${APP_NAME} is Apple Silicon only for now (current architecture: ${process.arch}).\n` +
|
|
116
|
+
'Intel Mac support needs native addons bundled in as well, and is tracked as separate work.'
|
|
80
117
|
)
|
|
81
118
|
}
|
|
82
119
|
const supported = Object.keys(TARGETS).join(', ')
|
|
83
120
|
return (
|
|
84
|
-
`${APP_NAME}
|
|
85
|
-
|
|
86
|
-
'https://github.com/ijun17/centralu/issues/14
|
|
121
|
+
`${APP_NAME} does not support ${PLATFORM}/${process.arch} yet.\n` +
|
|
122
|
+
`Currently supported combinations: ${supported}\n` +
|
|
123
|
+
'Progress is tracked at https://github.com/ijun17/centralu/issues/14.'
|
|
87
124
|
)
|
|
88
125
|
}
|
|
89
126
|
return (
|
|
90
|
-
|
|
91
|
-
'
|
|
127
|
+
`Could not find the app package (${TARGET.pkg}).\n` +
|
|
128
|
+
'The install may have been interrupted — please reinstall with `npm i -g centralu`.'
|
|
92
129
|
)
|
|
93
130
|
}
|
|
94
131
|
|
|
132
|
+
/**
|
|
133
|
+
* The bundled host's entry (`resources/host/main.mjs`) for `centralu serve`, or exit with why not:
|
|
134
|
+
* from the platform package, or from the host-only package where a hub installed this (`findHostEntry`).
|
|
135
|
+
*
|
|
136
|
+
* `CENTRALU_HOST_ENTRY` points it at another host instead: a source checkout's
|
|
137
|
+
* `packages/agent-host/src/main.ts` while developing, or a freshly bundled `resources/host/main.mjs`.
|
|
138
|
+
* The tests start `serve` that way (tooling/launcher-serve.test.ts).
|
|
139
|
+
*/
|
|
140
|
+
function requireHostEntry() {
|
|
141
|
+
if (process.env.CENTRALU_HOST_ENTRY) return process.env.CENTRALU_HOST_ENTRY
|
|
142
|
+
if (!TARGET) {
|
|
143
|
+
console.error(explainMissing())
|
|
144
|
+
process.exit(1)
|
|
145
|
+
}
|
|
146
|
+
// The platform package of an npm install, else the host-only package a hub installed (platform.mjs)
|
|
147
|
+
const found = findHostEntry(PLATFORM, process.arch, {
|
|
148
|
+
resolveRoot: (name) => {
|
|
149
|
+
try {
|
|
150
|
+
return dirname(require.resolve(`${name}/package.json`))
|
|
151
|
+
} catch {
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
exists: existsSync,
|
|
156
|
+
})
|
|
157
|
+
if (found.entry) return found.entry
|
|
158
|
+
if (found.missingHost) {
|
|
159
|
+
console.error(
|
|
160
|
+
`The host is missing from ${found.missingHost.pkg} (looked for ${found.missingHost.entry}).\n` +
|
|
161
|
+
'Linux packages published before `centralu serve` existed do not carry it. Update with `npm i -g centralu`.',
|
|
162
|
+
)
|
|
163
|
+
process.exit(1)
|
|
164
|
+
}
|
|
165
|
+
console.error(explainMissing())
|
|
166
|
+
process.exit(1)
|
|
167
|
+
}
|
|
168
|
+
|
|
95
169
|
function requireApp() {
|
|
96
170
|
const app = bundledApp()
|
|
97
171
|
if (!app) {
|
|
@@ -102,20 +176,27 @@ function requireApp() {
|
|
|
102
176
|
}
|
|
103
177
|
|
|
104
178
|
/**
|
|
105
|
-
*
|
|
179
|
+
* Launches the app. If it is installed in `/Applications` (macOS) or
|
|
180
|
+
* `%LOCALAPPDATA%\Programs\Centralu` (Windows), that copy is used first.
|
|
106
181
|
*
|
|
107
|
-
*
|
|
182
|
+
* Linux has no such preference, and not for lack of an equivalent: `centralu install`
|
|
108
183
|
* on Linux writes a launcher that points back at this same package, so there is never a
|
|
109
184
|
* second copy to prefer.
|
|
185
|
+
*
|
|
186
|
+
* Resolves once the launcher has nothing more to wait for. Only Windows waits at all —
|
|
187
|
+
* briefly, to report an exe that dies on start (see runWindows).
|
|
110
188
|
*/
|
|
111
|
-
function run(args) {
|
|
112
|
-
if (
|
|
189
|
+
async function run(args) {
|
|
190
|
+
if (PLATFORM === 'darwin') {
|
|
113
191
|
const app = existsSync(INSTALLED) ? INSTALLED : requireApp()
|
|
114
|
-
// `open
|
|
115
|
-
|
|
192
|
+
// `open` goes through LaunchServices — the dock icon and single-instance behavior only
|
|
193
|
+
// work correctly that way.
|
|
194
|
+
const plan = launchPlan(PLATFORM, app, args, HOME)
|
|
195
|
+
const r = spawn(plan.command, plan.args, plan.options)
|
|
116
196
|
r.on('exit', (code) => process.exit(code ?? 0))
|
|
117
197
|
return
|
|
118
198
|
}
|
|
199
|
+
if (PLATFORM === 'win32') return runWindows(args)
|
|
119
200
|
// Linux has no LaunchServices — the AppImage is its own launcher, so run it directly.
|
|
120
201
|
//
|
|
121
202
|
// Staying attached to the terminal is on purpose. The two usual first failures on
|
|
@@ -124,34 +205,115 @@ function run(args) {
|
|
|
124
205
|
// missing system library from the loader. Detaching would hand back the shell prompt
|
|
125
206
|
// and throw away the one line that says what to do.
|
|
126
207
|
const app = requireApp()
|
|
127
|
-
const
|
|
208
|
+
const plan = launchPlan(PLATFORM, app, args, HOME)
|
|
209
|
+
const r = spawn(plan.command, plan.args, plan.options)
|
|
128
210
|
r.on('error', (e) => {
|
|
129
|
-
console.error(
|
|
211
|
+
console.error(`Failed to run ${app}: ${e.message}`)
|
|
130
212
|
// EACCES here means the executable bit did not survive the trip through npm, which
|
|
131
213
|
// is invisible from the message alone.
|
|
132
|
-
if (e.code === 'EACCES') console.error(
|
|
214
|
+
if (e.code === 'EACCES') console.error(`No execute permission — run \`chmod +x "${app}"\` then try again.`)
|
|
133
215
|
process.exit(1)
|
|
134
216
|
})
|
|
135
217
|
r.on('exit', (code) => process.exit(code ?? 0))
|
|
136
218
|
}
|
|
137
219
|
|
|
220
|
+
/** How long to watch a freshly started exe for an immediate exit */
|
|
221
|
+
const EARLY_EXIT_MS = 3000
|
|
222
|
+
|
|
138
223
|
/**
|
|
139
|
-
*
|
|
224
|
+
* Windows: check WebView2, start `centralu.exe` detached, and watch it for a few seconds.
|
|
140
225
|
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
226
|
+
* The app is a GUI program, so a failure to start has nowhere to print. Without WebView2
|
|
227
|
+
* the exe ends before it has a window, and the person sees nothing happen at all — the
|
|
228
|
+
* registry check turns that into a message with the download link before anything starts.
|
|
229
|
+
* The short watch afterwards catches whatever the registry cannot see (a broken runtime, a
|
|
230
|
+
* blocked exe), then lets go so the app outlives this launcher.
|
|
143
231
|
*/
|
|
144
|
-
function
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
rmSync(INSTALLED, { recursive: true, force: true })
|
|
232
|
+
async function runWindows(args) {
|
|
233
|
+
const webview = webview2Status(process.env, queryRegistry)
|
|
234
|
+
if (webview.state === 'missing') {
|
|
235
|
+
console.error(webview2MissingMessage())
|
|
236
|
+
process.exit(1)
|
|
150
237
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
238
|
+
const app = existsSync(WIN.exe) ? WIN.dir : requireApp()
|
|
239
|
+
const plan = launchPlan(PLATFORM, app, args, HOME)
|
|
240
|
+
const child = spawn(plan.command, plan.args, plan.options)
|
|
241
|
+
await new Promise((resolve) => {
|
|
242
|
+
const done = () => {
|
|
243
|
+
clearTimeout(timer)
|
|
244
|
+
child.removeAllListeners()
|
|
245
|
+
child.unref()
|
|
246
|
+
resolve()
|
|
247
|
+
}
|
|
248
|
+
const timer = setTimeout(done, EARLY_EXIT_MS)
|
|
249
|
+
child.on('error', (e) => {
|
|
250
|
+
console.error(`Failed to start ${plan.command}: ${e.message}`)
|
|
251
|
+
process.exitCode = 1
|
|
252
|
+
done()
|
|
253
|
+
})
|
|
254
|
+
child.on('exit', (code) => {
|
|
255
|
+
// 0 is a normal answer this early: a second start hands over to the window that is
|
|
256
|
+
// already open and leaves.
|
|
257
|
+
if (code !== 0 && code !== null) {
|
|
258
|
+
console.error(earlyExitMessage(code, HOME))
|
|
259
|
+
process.exitCode = 1
|
|
260
|
+
}
|
|
261
|
+
done()
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* `reg query <key> /v pv`: stdout, null when the key or value does not exist (reg exits 1),
|
|
268
|
+
* undefined when reg itself could not run.
|
|
269
|
+
*/
|
|
270
|
+
function queryRegistry(key) {
|
|
271
|
+
try {
|
|
272
|
+
return execFileSync(regExe(process.env), ['query', key, '/v', 'pv'], {
|
|
273
|
+
encoding: 'utf8',
|
|
274
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
275
|
+
windowsHide: true,
|
|
276
|
+
})
|
|
277
|
+
} catch (e) {
|
|
278
|
+
return typeof e?.status === 'number' ? null : undefined
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Registers the app in the system menu.
|
|
284
|
+
*
|
|
285
|
+
* **Never done quietly through postinstall.** Writing silently into someone's `/Applications`
|
|
286
|
+
* erodes trust, and pnpm blocks postinstall by default anyway. This is only run when the
|
|
287
|
+
* person explicitly asks for it.
|
|
288
|
+
*
|
|
289
|
+
* **Never over a newer local build** (#645), unless `--force`. The in-app update runs this after
|
|
290
|
+
* `npm i -g` with the same rule (`keepsInstalledCopy`): a maintainer's local build
|
|
291
|
+
* (`0.2.0-beta.21.local.1`) waiting for "Apply now" would otherwise be replaced by npm's beta.21.
|
|
292
|
+
* Any released version is replaced, even a higher one: npm is the source of truth for releases,
|
|
293
|
+
* and a release pulled from `latest` must not survive here. The check comes before finding the app
|
|
294
|
+
* package, since keeping the copy needs nothing from it. `--force` replaces a local build on purpose.
|
|
295
|
+
*/
|
|
296
|
+
function install(force) {
|
|
297
|
+
const copy = installedCopyVersion()
|
|
298
|
+
if (!force && keepsInstalledCopy(pkg.version, copy)) {
|
|
299
|
+
const where = PLATFORM === 'win32' ? WIN.dir : INSTALLED
|
|
300
|
+
console.log(`${where} holds the local build ${copy}, newer than this package (${pkg.version}), and was left as it is.`)
|
|
301
|
+
console.log('To replace it anyway: centralu install --force')
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
const app = requireApp()
|
|
305
|
+
if (PLATFORM === 'win32') return installWindows(app)
|
|
306
|
+
if (PLATFORM !== 'darwin') return installDesktopEntry(app)
|
|
307
|
+
if (existsSync(INSTALLED)) console.log(`Replacing the existing ${INSTALLED} with the new version.`)
|
|
308
|
+
// Copied beside the old one and swapped in by rename (swap.mjs): a copy that fails leaves the
|
|
309
|
+
// old app in place rather than none. ditto, not cp — carries over the bundle's permissions and
|
|
310
|
+
// extended attributes unchanged (so the signature does not break).
|
|
311
|
+
const { leftAt } = swapInto(INSTALLED, (staging) => {
|
|
312
|
+
execFileSync('/usr/bin/ditto', [app, staging], { stdio: 'inherit' })
|
|
313
|
+
})
|
|
314
|
+
if (leftAt) console.log(`The previous copy could not be removed and was left at ${leftAt}. Delete it yourself.`)
|
|
315
|
+
console.log(`Installed: ${INSTALLED}`)
|
|
316
|
+
console.log('It can now be found in Launchpad and Spotlight too.')
|
|
155
317
|
}
|
|
156
318
|
|
|
157
319
|
/**
|
|
@@ -184,27 +346,117 @@ function installDesktopEntry(app) {
|
|
|
184
346
|
mkdirSync(dirname(DESKTOP_ENTRY), { recursive: true })
|
|
185
347
|
writeFileSync(DESKTOP_ENTRY, entry)
|
|
186
348
|
chmodSync(DESKTOP_ENTRY, 0o755)
|
|
187
|
-
console.log(
|
|
188
|
-
console.log('
|
|
349
|
+
console.log(`Registered: ${DESKTOP_ENTRY}`)
|
|
350
|
+
console.log('It can now be found in the app list too (some desktops require logging back in before it shows up).')
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The Windows answer to "copy it into /Applications": copy the folder into
|
|
355
|
+
* `%LOCALAPPDATA%\Programs\Centralu` and put a shortcut in the Start menu.
|
|
356
|
+
*
|
|
357
|
+
* A copy rather than a shortcut into the npm package (the Linux shape), because Windows
|
|
358
|
+
* will not replace a running program's files: with the app running from the package,
|
|
359
|
+
* `npm i -g centralu@newer` fails. Running from the copy leaves the package free to update.
|
|
360
|
+
*
|
|
361
|
+
* The old copy is never deleted before the new one is complete. The new folder is
|
|
362
|
+
* assembled beside it, the old one is renamed aside, and only then is the new one moved in
|
|
363
|
+
* — so a copy that fails halfway, or an app that is still running, leaves the working
|
|
364
|
+
* install untouched rather than a folder with half its files.
|
|
365
|
+
*/
|
|
366
|
+
function installWindows(app) {
|
|
367
|
+
const staging = `${WIN.dir}.new`
|
|
368
|
+
const aside = `${WIN.dir}.old-${Date.now()}`
|
|
369
|
+
sweepAsideCopies()
|
|
370
|
+
rmSync(staging, { recursive: true, force: true })
|
|
371
|
+
mkdirSync(dirname(WIN.dir), { recursive: true })
|
|
372
|
+
cpSync(app, staging, { recursive: true })
|
|
373
|
+
writeFileSync(join(staging, 'centralu-version.txt'), `${pkg.version}\n`)
|
|
374
|
+
if (existsSync(WIN.dir)) {
|
|
375
|
+
console.log(`Replacing the existing ${WIN.dir} with the new version.`)
|
|
376
|
+
try {
|
|
377
|
+
renameSync(WIN.dir, aside)
|
|
378
|
+
} catch (e) {
|
|
379
|
+
rmSync(staging, { recursive: true, force: true })
|
|
380
|
+
if (!isBusyError(e)) throw e
|
|
381
|
+
console.error(busyMessage(WIN.dir))
|
|
382
|
+
process.exit(1)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
renameSync(staging, WIN.dir)
|
|
386
|
+
try {
|
|
387
|
+
rmSync(aside, { recursive: true, force: true })
|
|
388
|
+
} catch {
|
|
389
|
+
// A rename can succeed while the old copy is running; its files then stay locked until
|
|
390
|
+
// it quits. Harmless — it is no longer the one anything starts — but worth a line.
|
|
391
|
+
console.log(`The previous copy is still in use and was left at ${aside}. Delete it after quitting ${APP_NAME}.`)
|
|
392
|
+
}
|
|
393
|
+
const sc = shortcutCommand(process.env, { shortcut: WIN.shortcut, target: WIN.exe, workdir: HOME })
|
|
394
|
+
try {
|
|
395
|
+
execFileSync(sc.command, sc.args, { env: sc.env, stdio: ['ignore', 'ignore', 'inherit'], windowsHide: true })
|
|
396
|
+
} catch (e) {
|
|
397
|
+
console.log(`Installed: ${WIN.dir}`)
|
|
398
|
+
console.error(`Could not create the Start menu shortcut (${e.message}). Start ${WIN.exe} directly, or run \`centralu\`.`)
|
|
399
|
+
process.exit(1)
|
|
400
|
+
}
|
|
401
|
+
console.log(`Installed: ${WIN.dir}`)
|
|
402
|
+
console.log(`Start menu: ${WIN.shortcut}`)
|
|
403
|
+
console.log('It can now be found in Start and Windows search, and `centralu` starts this copy too.')
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Removes the copies earlier installs renamed aside (`Centralu.old-<time>`), whichever can go.
|
|
408
|
+
*
|
|
409
|
+
* An update from inside the app renames the running copy aside, and Windows will not delete a
|
|
410
|
+
* running exe, so that copy stays until the app quits. Nothing else would ever remove it, and
|
|
411
|
+
* each in-app update would leave one more (about 70 files each, measured 2026-10-08). One still
|
|
412
|
+
* in use is left for the next install; deleting what can be deleted from it does not disturb the
|
|
413
|
+
* app running from it, which reaches its files by the folder's original path, now the new copy.
|
|
414
|
+
*/
|
|
415
|
+
function sweepAsideCopies() {
|
|
416
|
+
const parent = dirname(WIN.dir)
|
|
417
|
+
let names
|
|
418
|
+
try {
|
|
419
|
+
names = readdirSync(parent)
|
|
420
|
+
} catch {
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
for (const name of asideCopies(names)) {
|
|
424
|
+
try {
|
|
425
|
+
rmSync(join(parent, name), { recursive: true, force: true })
|
|
426
|
+
} catch {
|
|
427
|
+
// Still in use: the next install tries again
|
|
428
|
+
}
|
|
429
|
+
}
|
|
189
430
|
}
|
|
190
431
|
|
|
191
432
|
function uninstall() {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
433
|
+
const paths = installedPaths(PLATFORM, process.env, HOME)
|
|
434
|
+
const present = paths.filter((p) => existsSync(p))
|
|
435
|
+
if (present.length === 0) {
|
|
436
|
+
console.log(`${paths[0]} does not exist — nothing to remove.`)
|
|
195
437
|
return
|
|
196
438
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
439
|
+
for (const installed of present) {
|
|
440
|
+
try {
|
|
441
|
+
rmSync(installed, { recursive: true, force: true })
|
|
442
|
+
} catch (e) {
|
|
443
|
+
if (PLATFORM !== 'win32' || !isBusyError(e)) throw e
|
|
444
|
+
console.error(busyMessage(installed))
|
|
445
|
+
process.exit(1)
|
|
446
|
+
}
|
|
447
|
+
console.log(`Removed: ${installed}`)
|
|
448
|
+
}
|
|
449
|
+
console.log('To remove the package itself: npm uninstall -g centralu')
|
|
450
|
+
const data = PLATFORM === 'win32' ? '%USERPROFILE%\\.centralu' : '~/.centralu'
|
|
451
|
+
console.log(`Conversation history is left in place (${data}). Remove it yourself if you want to.`)
|
|
201
452
|
}
|
|
202
453
|
|
|
203
454
|
/**
|
|
204
|
-
* npm
|
|
455
|
+
* The npm registry itself is the update channel — no separate server or signing key needed.
|
|
205
456
|
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
457
|
+
* Failures are not lumped together. Being unreachable (network) and not existing (404) call
|
|
458
|
+
* for **different actions from the person** — the first says to check the connection, the
|
|
459
|
+
* second says checking will not help.
|
|
208
460
|
*/
|
|
209
461
|
async function latestVersion(timeoutMs = 2000) {
|
|
210
462
|
try {
|
|
@@ -217,7 +469,7 @@ async function latestVersion(timeoutMs = 2000) {
|
|
|
217
469
|
const version = body?.version
|
|
218
470
|
return typeof version === 'string' ? { ok: true, version } : { ok: false, reason: 'bad-response' }
|
|
219
471
|
} catch {
|
|
220
|
-
return { ok: false, reason: 'network' } //
|
|
472
|
+
return { ok: false, reason: 'network' } // The app has to launch even with no network.
|
|
221
473
|
}
|
|
222
474
|
}
|
|
223
475
|
|
|
@@ -226,56 +478,129 @@ async function update() {
|
|
|
226
478
|
const res = await latestVersion(8000)
|
|
227
479
|
if (!res.ok) {
|
|
228
480
|
if (res.reason === 'missing') {
|
|
229
|
-
console.error(
|
|
230
|
-
console.error('https://github.com/ijun17/centralu/releases
|
|
481
|
+
console.error(`${pkg.name} is not on the registry. The name may have changed, or it has not been published yet.`)
|
|
482
|
+
console.error('Please check https://github.com/ijun17/centralu/releases.')
|
|
231
483
|
} else if (res.reason === 'network') {
|
|
232
|
-
console.error('
|
|
484
|
+
console.error('Could not reach the registry. Please check your network and try again.')
|
|
233
485
|
} else {
|
|
234
|
-
console.error(
|
|
486
|
+
console.error(`The registry gave an unexpected response (${res.reason}).`)
|
|
235
487
|
}
|
|
236
488
|
process.exit(1)
|
|
237
489
|
}
|
|
238
490
|
const latest = res.version
|
|
239
491
|
if (!isNewer(latest, pkg.version)) {
|
|
240
|
-
console.log(
|
|
492
|
+
console.log(`Already up to date (${pkg.version}).`)
|
|
493
|
+
// The package can be up to date while the copy is not — that is exactly the state of
|
|
494
|
+
// someone who upgraded with `npm i -g`.
|
|
495
|
+
notifyIfCopyStale()
|
|
241
496
|
return
|
|
242
497
|
}
|
|
243
|
-
console.log(
|
|
244
|
-
|
|
245
|
-
|
|
498
|
+
console.log(`Upgrading ${pkg.version} → ${latest}.`)
|
|
499
|
+
const npm = npmCommand(PLATFORM, ['i', '-g', `${pkg.name}@${latest}`])
|
|
500
|
+
try {
|
|
501
|
+
execFileSync(npm.command, npm.args, npm.options)
|
|
502
|
+
} catch (e) {
|
|
503
|
+
// On Windows the usual cause is the app running from inside the package: its files are
|
|
504
|
+
// locked, and npm cannot replace them. npm's own output (above) has the details.
|
|
505
|
+
if (PLATFORM === 'win32') console.error(`\n${busyMessage('the npm package')}`)
|
|
506
|
+
process.exit(typeof e?.status === 'number' ? e.status : 1)
|
|
507
|
+
}
|
|
508
|
+
// Someone with a copy in /Applications (or %LOCALAPPDATA%\Programs) needs that updated
|
|
509
|
+
// too, or the old version stays behind.
|
|
246
510
|
// On Linux the menu entry points at the package instead of a copy, so rewriting it is
|
|
247
511
|
// cheap — but it is still worth doing, because the Exec path is what would go stale.
|
|
248
|
-
|
|
512
|
+
//
|
|
513
|
+
// The *new* launcher does the refresh: this file has just been replaced on disk. It is
|
|
514
|
+
// run through `node` rather than as a script, because Windows cannot execute a `.mjs`.
|
|
515
|
+
const installed = installedPaths(PLATFORM, process.env, HOME)[0]
|
|
249
516
|
if (existsSync(installed)) {
|
|
250
|
-
console.log(
|
|
251
|
-
execFileSync(process.argv[1],
|
|
517
|
+
console.log(`Updating ${installed} too.`)
|
|
518
|
+
execFileSync(process.execPath, [process.argv[1], 'install'], { stdio: 'inherit' })
|
|
519
|
+
}
|
|
520
|
+
console.log('Done. If the app is open, please restart it.')
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The version of the copy placed in /Applications. null if there is nothing to compare.
|
|
525
|
+
* (`bundleVersionCommand`: the in-app update reads it with the same command.)
|
|
526
|
+
*
|
|
527
|
+
* `defaults` is used because there is no guarantee Info.plist stays XML — Tauri just happens
|
|
528
|
+
* to write it as XML today, and if it ever switches to a binary plist, anything reading it
|
|
529
|
+
* with a regex would silently stop working. `defaults` is always present on a Mac and reads
|
|
530
|
+
* both formats.
|
|
531
|
+
*
|
|
532
|
+
* On Windows it is the file `centralu install` wrote beside the copy (see `windowsInstall`
|
|
533
|
+
* for why not the exe's own version).
|
|
534
|
+
*/
|
|
535
|
+
function installedCopyVersion() {
|
|
536
|
+
if (PLATFORM === 'win32') {
|
|
537
|
+
try {
|
|
538
|
+
return readFileSync(WIN.versionFile, 'utf8').trim()
|
|
539
|
+
} catch {
|
|
540
|
+
return null
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (PLATFORM !== 'darwin' || !existsSync(INSTALLED)) return null
|
|
544
|
+
try {
|
|
545
|
+
const read = bundleVersionCommand(INSTALLED)
|
|
546
|
+
const out = execFileSync(read.command, read.args, {
|
|
547
|
+
encoding: 'utf8',
|
|
548
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
549
|
+
})
|
|
550
|
+
return out.trim()
|
|
551
|
+
} catch {
|
|
552
|
+
// Failing to read this is not worth mentioning — the app has already launched, and there
|
|
553
|
+
// is nothing useful to say here.
|
|
554
|
+
return null
|
|
252
555
|
}
|
|
253
|
-
console.log('끝났습니다. 앱이 떠 있다면 다시 시작해 주세요.')
|
|
254
556
|
}
|
|
255
557
|
|
|
256
|
-
/**
|
|
558
|
+
/**
|
|
559
|
+
* One line, if the package and the /Applications copy have drifted apart.
|
|
560
|
+
*
|
|
561
|
+
* `npm i -g centralu` only changes the package. So a state quietly forms where the launcher is
|
|
562
|
+
* new but the app that opens from Spotlight is old — this actually happened (beta.1 stayed in
|
|
563
|
+
* /Applications while only the package moved to beta.3), and nothing on screen said so.
|
|
564
|
+
*
|
|
565
|
+
* **Says it, does not do it.** The same rule as the in-app update line. Overwriting someone's
|
|
566
|
+
* /Applications without asking is the exact reason this package keeps `install` as a separate
|
|
567
|
+
* step.
|
|
568
|
+
*/
|
|
569
|
+
function notifyIfCopyStale() {
|
|
570
|
+
const version = installedCopyVersion()
|
|
571
|
+
if (!copyDiffers(pkg.version, version)) return
|
|
572
|
+
const where = PLATFORM === 'win32' ? WIN.dir : '/Applications'
|
|
573
|
+
console.log(`\nThe ${where} copy is ${version} (this package is ${pkg.version}).\n centralu install`)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Only announced after the app has launched — checking for updates must never delay startup. */
|
|
257
577
|
async function notifyIfOutdated() {
|
|
258
|
-
//
|
|
578
|
+
// Every failure here is **swallowed entirely.** Someone who came here to launch the app has
|
|
579
|
+
// no reason to be told about registry troubles.
|
|
259
580
|
const res = await latestVersion()
|
|
260
581
|
if (res.ok && isNewer(res.version, pkg.version)) {
|
|
261
|
-
console.log(`\
|
|
582
|
+
console.log(`\nA new version is available: ${pkg.version} → ${res.version}\n centralu update`)
|
|
262
583
|
}
|
|
263
584
|
}
|
|
264
585
|
|
|
265
586
|
const HELP = `${APP_NAME} ${pkg.version}
|
|
266
587
|
|
|
267
|
-
centralu
|
|
268
|
-
centralu install
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
centralu
|
|
588
|
+
centralu Launches the app
|
|
589
|
+
centralu install Registers it in the app list (macOS: /Applications, Linux: a menu entry,
|
|
590
|
+
Windows: %LOCALAPPDATA%\\Programs\\Centralu and a Start menu shortcut).
|
|
591
|
+
A newer local build there is kept; --force replaces it anyway
|
|
592
|
+
centralu uninstall Removes that registration (conversation history is kept)
|
|
593
|
+
centralu update Upgrades if a new version is available
|
|
594
|
+
centralu serve Runs the host alone on this machine, for the app on another computer
|
|
595
|
+
to reach over SSH (\`centralu serve --help\`)
|
|
596
|
+
centralu --version Prints the version
|
|
272
597
|
|
|
273
|
-
|
|
598
|
+
Requires: Node ${NODE_FLOOR}+, and the claude or codex CLI (the app's first screen reports the status)`
|
|
274
599
|
|
|
275
600
|
const [cmd, ...rest] = process.argv.slice(2)
|
|
276
601
|
switch (cmd) {
|
|
277
602
|
case 'install':
|
|
278
|
-
install()
|
|
603
|
+
install(rest.includes('--force'))
|
|
279
604
|
break
|
|
280
605
|
case 'uninstall':
|
|
281
606
|
uninstall()
|
|
@@ -283,6 +608,67 @@ switch (cmd) {
|
|
|
283
608
|
case 'update':
|
|
284
609
|
await update()
|
|
285
610
|
break
|
|
611
|
+
case 'serve': {
|
|
612
|
+
// No update notice here: `--connection`'s stdout is read by a program, and a foreground
|
|
613
|
+
// server's terminal is a log.
|
|
614
|
+
const args = parseServeArgs(rest)
|
|
615
|
+
if (args.error) {
|
|
616
|
+
console.error(args.error)
|
|
617
|
+
process.exit(2)
|
|
618
|
+
}
|
|
619
|
+
if (args.mode === 'help') {
|
|
620
|
+
console.log(SERVE_HELP)
|
|
621
|
+
break
|
|
622
|
+
}
|
|
623
|
+
try {
|
|
624
|
+
if (args.mode === 'rotate') {
|
|
625
|
+
process.exitCode = rotateToken({ env: process.env, home: HOME })
|
|
626
|
+
break
|
|
627
|
+
}
|
|
628
|
+
/*
|
|
629
|
+
* Before anything spawns a host: under a Node below the floor the host crashes on its first
|
|
630
|
+
* database call, with nothing in its log (issue #699). `--detach` also answers in the line the
|
|
631
|
+
* hub reads, so it can offer its managed install instead of quoting a crash. `--autostart on`
|
|
632
|
+
* writes a boot entry that would start that host. `--connection`, `--stop`, `--autostart
|
|
633
|
+
* off|status` and `--rotate-token` start no host and keep working, so the hub can still ask this
|
|
634
|
+
* machine what Node it has.
|
|
635
|
+
*/
|
|
636
|
+
const startsHost = args.mode === 'serve' || args.mode === 'detach' || args.mode === 'window' || (args.mode === 'autostart' && args.autostart === 'on')
|
|
637
|
+
if (startsHost && belowFloor(process.versions.node, NODE_FLOOR)) {
|
|
638
|
+
const message = oldNodeSentence(process.versions.node, NODE_FLOOR)
|
|
639
|
+
console.error(`centralu serve: ${message}`)
|
|
640
|
+
if (args.mode === 'detach') {
|
|
641
|
+
const line = { v: 1, detach: { ok: false, how: null, reason: 'node_too_old', message, node: process.versions.node, floor: NODE_FLOOR } }
|
|
642
|
+
process.stdout.write(`${JSON.stringify(line)}\n`)
|
|
643
|
+
}
|
|
644
|
+
// Not process.exit: the line above is on a pipe, and exiting at once may cut it
|
|
645
|
+
process.exitCode = 1
|
|
646
|
+
break
|
|
647
|
+
}
|
|
648
|
+
// A launcher `--detach` started: what it carried (the data folder, a host entry) applies before
|
|
649
|
+
// anything below reads the environment
|
|
650
|
+
const spec = args.child !== undefined ? decodeChildSpec(args.child) : null
|
|
651
|
+
if (args.child !== undefined && !spec) {
|
|
652
|
+
console.error('centralu serve: --detached-child is how --detach starts serve; this one could not be read')
|
|
653
|
+
process.exit(2)
|
|
654
|
+
}
|
|
655
|
+
if (spec) Object.assign(process.env, spec.env)
|
|
656
|
+
// realpath: npm's bin is a symlink to this file, and the launcher must not depend on the link
|
|
657
|
+
const opts = { env: process.env, home: HOME, entry: requireHostEntry(), version: pkg.version, cliPath: realpathSync(process.argv[1]) }
|
|
658
|
+
if (args.mode === 'connection') process.exitCode = await printConnection(opts)
|
|
659
|
+
else if (args.mode === 'detach') process.exitCode = await runDetach({ ...opts, port: args.port, from: args.from })
|
|
660
|
+
else if (args.mode === 'stop') process.exitCode = await runStop(opts)
|
|
661
|
+
else if (args.mode === 'window') process.exitCode = await runWindow(opts)
|
|
662
|
+
else if (args.mode === 'autostart') process.exitCode = await runAutostart({ ...opts, action: args.autostart })
|
|
663
|
+
else if (spec) process.exitCode = await runDetachedChild({ ...opts, spec, port: args.port })
|
|
664
|
+
else process.exitCode = await runServe({ ...opts, port: args.port, from: args.from })
|
|
665
|
+
} catch (e) {
|
|
666
|
+
// A state file that cannot be read or written: one sentence, not a stack, and nothing on stdout
|
|
667
|
+
console.error(`centralu serve: ${e?.message ?? e}`)
|
|
668
|
+
process.exitCode = 1
|
|
669
|
+
}
|
|
670
|
+
break
|
|
671
|
+
}
|
|
286
672
|
case '--version':
|
|
287
673
|
case '-v':
|
|
288
674
|
console.log(pkg.version)
|
|
@@ -292,7 +678,10 @@ switch (cmd) {
|
|
|
292
678
|
case 'help':
|
|
293
679
|
console.log(HELP)
|
|
294
680
|
break
|
|
295
|
-
default:
|
|
296
|
-
|
|
297
|
-
|
|
681
|
+
default: {
|
|
682
|
+
// Started before the update check, never after it: the check must not delay startup.
|
|
683
|
+
const launched = run(cmd ? [cmd, ...rest] : [])
|
|
684
|
+
notifyIfCopyStale()
|
|
685
|
+
await Promise.all([launched, notifyIfOutdated()])
|
|
686
|
+
}
|
|
298
687
|
}
|