centralu 0.1.0-beta.4 → 0.1.0-beta.40

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
@@ -1,23 +1,64 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Centralu 실행기.
3
+ * The Centralu launcher.
4
4
  *
5
- * **왜 npm으로 주는가:** macOS는 앱을 검사해서 위험하다고 판정하는 게 아니라,
6
- * 파일에 붙은 `com.apple.quarantine` 딱지를 보고 개발자 신원을 따진다. 그 딱지는
7
- * **받아온 프로그램이** 붙인다 — 브라우저는 붙이고 npm은 안 붙인다. 그래서 npm으로
8
- * 설치하면 서명이 애드혹이어도 경고 없이 그냥 열린다 (docs/plans/beta-release-checklist.md §2).
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
- * 앱 본체는 아키텍처별 패키지(`centralu-darwin-arm64`)에 들어 있다. 이 패키지는
11
- * 그것을 찾아 띄우는 얇은 껍데기다 — esbuild·swc가 쓰는 것과 같은 구조.
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 { copyDiffers, isNewer } from './semver.mjs'
20
- import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
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
- const INSTALLED = `/Applications/${BUNDLE}`
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(homedir(), '.local/share/applications/centralu.desktop')
84
+ const DESKTOP_ENTRY = join(HOME, '.local/share/applications/centralu.desktop')
34
85
 
35
- /**
36
- * The arch package for this machine, and what the app is called inside it.
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
- const TARGET = TARGETS[`${process.platform}-${process.arch}`]
89
+ /** The arch package for this machine (the table lives in platform.mjs) */
90
+ const TARGET = targetFor(PLATFORM, process.arch)
54
91
 
55
- /** 아키텍처별 패키지 안의 앱. 못 찾으면 null */
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 (process.platform === 'darwin') {
113
+ if (PLATFORM === 'darwin') {
77
114
  return (
78
- `${APP_NAME}는 아직 Apple Silicon 전용입니다 (지금 아키텍처: ${process.arch}).\n` +
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}는 아직 ${process.platform}/${process.arch}를 지원하지 않습니다.\n` +
85
- `지금 지원하는 조합: ${supported}\n` +
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
- `앱 본체 패키지(${TARGET.pkg})를 찾지 못했습니다.\n` +
91
- '설치가 중간에 끊겼을 수 있습니다 — `npm i -g centralu`로 다시 설치해 주세요.'
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
- * 앱을 띄운다. `/Applications`에 설치돼 있으면 그쪽을 먼저 쓴다.
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
- * That preference is macOS-only, and not for lack of an equivalent: `centralu install`
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 (process.platform === 'darwin') {
189
+ async function run(args) {
190
+ if (PLATFORM === 'darwin') {
113
191
  const app = existsSync(INSTALLED) ? INSTALLED : requireApp()
114
- // `open`은 LaunchServices를 거친다 — Dock 아이콘·단일 인스턴스가 그래야 제대로 산다
115
- const r = spawn('open', ['-a', app, ...(args.length ? ['--args', ...args] : [])], { stdio: 'inherit' })
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 r = spawn(app, args, { stdio: 'inherit' })
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(`${app}를 실행하지 못했습니다: ${e.message}`)
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(`실행 권한이 없습니다 — \`chmod +x "${app}"\` 뒤에 다시 시도해 주세요.`)
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
- * **postinstall로 몰래 하지 않는다.** 남의 `/Applications`에 조용히 쓰는 것은 신뢰를 깎고,
142
- * pnpm은 postinstall을 기본 차단한다. 사용자가 원할 때 명시적으로 부르게 한다.
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 install() {
145
- const app = requireApp()
146
- if (process.platform !== 'darwin') return installDesktopEntry(app)
147
- if (existsSync(INSTALLED)) {
148
- console.log(`기존 ${INSTALLED}를 새 버전으로 교체합니다.`)
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)
237
+ }
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
150
279
  }
151
- // cp가 아니라 ditto — 번들의 권한·확장속성을 그대로 옮긴다 (서명이 깨지지 않게)
152
- execFileSync('/usr/bin/ditto', [app, INSTALLED], { stdio: 'inherit' })
153
- console.log(`설치했습니다: ${INSTALLED}`)
154
- console.log('이제 Launchpad·Spotlight에서도 찾을 수 있습니다.')
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(`등록했습니다: ${DESKTOP_ENTRY}`)
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 installed = process.platform === 'darwin' ? INSTALLED : DESKTOP_ENTRY
193
- if (!existsSync(installed)) {
194
- console.log(`${installed}가 없습니다 — 지울 것이 없습니다.`)
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
- rmSync(installed, { recursive: true, force: true })
198
- console.log(`지웠습니다: ${installed}`)
199
- console.log('패키지 자체를 지우려면: npm uninstall -g centralu')
200
- console.log(`대화 기록은 그대로 남아 있습니다 (~/.centralu). 지우려면 직접 지우세요.`)
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
- * 실패를 뭉뚱그리지 않는다. 못 닿은 것(네트워크)과 없는 것(404)은 **사용자가 할 일이 다르다** —
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,95 +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(`레지스트리에 ${pkg.name}가 없습니다. 이름이 바뀌었거나 아직 발행 전입니다.`)
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(`레지스트리가 예상 밖의 응답을 했습니다 (${res.reason}).`)
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(`이미 최신입니다 (${pkg.version}).`)
241
- // 패키지가 최신이어도 사본은 아닐 수 있다 — `npm i -g`로 올린 사람이 정확히 그 상태다
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`.
242
495
  notifyIfCopyStale()
243
496
  return
244
497
  }
245
- console.log(`${pkg.version} → ${latest} 로 올립니다.`)
246
- execFileSync('npm', ['i', '-g', `${pkg.name}@${latest}`], { stdio: 'inherit' })
247
- // /Applications에 넣어둔 사람은 그쪽도 함께 갱신해야 옛 버전이 남지 않는다.
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.
248
510
  // On Linux the menu entry points at the package instead of a copy, so rewriting it is
249
511
  // cheap — but it is still worth doing, because the Exec path is what would go stale.
250
- const installed = process.platform === 'darwin' ? INSTALLED : DESKTOP_ENTRY
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]
251
516
  if (existsSync(installed)) {
252
- console.log(`${installed}도 갱신합니다.`)
253
- execFileSync(process.argv[1], ['install'], { stdio: 'inherit' })
517
+ console.log(`Updating ${installed} too.`)
518
+ execFileSync(process.execPath, [process.argv[1], 'install'], { stdio: 'inherit' })
254
519
  }
255
- console.log('끝났습니다. 앱이 떠 있다면 다시 시작해 주세요.')
520
+ console.log('Done. If the app is open, please restart it.')
256
521
  }
257
522
 
258
523
  /**
259
- * /Applications에 넣어둔 사본의 버전. 견줄 것이 없으면 null.
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.
260
531
  *
261
- * `defaults`를 쓰는 이유는 Info.plist가 XML이라는 보장이 없어서다 — 오늘 Tauri가 XML로
262
- * 쓰고 있을 뿐이고, 바이너리 plist로 바뀌면 정규식으로 읽던 쪽이 조용히 못 읽게 된다.
263
- * `defaults`는 맥에 항상 있고 둘 다 읽는다.
532
+ * On Windows it is the file `centralu install` wrote beside the copy (see `windowsInstall`
533
+ * for why not the exe's own version).
264
534
  */
265
535
  function installedCopyVersion() {
266
- if (process.platform !== 'darwin' || !existsSync(INSTALLED)) return null
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
267
544
  try {
268
- const out = execFileSync('/usr/bin/defaults', ['read', join(INSTALLED, 'Contents/Info.plist'), 'CFBundleShortVersionString'], {
545
+ const read = bundleVersionCommand(INSTALLED)
546
+ const out = execFileSync(read.command, read.args, {
269
547
  encoding: 'utf8',
270
548
  stdio: ['ignore', 'pipe', 'ignore'],
271
549
  })
272
550
  return out.trim()
273
551
  } catch {
274
- // 못 읽는 것은 말할 거리가 아니다 — 앱은 이미 떴고, 여기서 할 수 있는 말이 없다
552
+ // Failing to read this is not worth mentioning — the app has already launched, and there
553
+ // is nothing useful to say here.
275
554
  return null
276
555
  }
277
556
  }
278
557
 
279
558
  /**
280
- * 패키지와 /Applications 사본이 어긋났으면 한 줄.
559
+ * One line, if the package and the /Applications copy have drifted apart.
281
560
  *
282
- * `npm i -g centralu`는 패키지만 바꾼다. 그래서 런처는 새것인데 Spotlight에서 열리는 앱은
283
- * 옛것인 상태가 조용히 만들어진다 — 실제로 그렇게 됐고(beta.1이 /Applications에 남아 있는
284
- * 채로 패키지만 beta.3), 화면 어디에도 그 사실이 없었다.
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.
285
564
  *
286
- * **말하되 대신 하지 않는다.** 앱 안의 업데이트 줄과 같은 규칙이다. 묻지도 않고 남의
287
- * /Applications를 덮는 일은 이 패키지가 `install`을 따로 둔 이유 그 자체다.
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.
288
568
  */
289
569
  function notifyIfCopyStale() {
290
570
  const version = installedCopyVersion()
291
571
  if (!copyDiffers(pkg.version, version)) return
292
- console.log(`\n/Applications 사본은 ${version}입니다 (이 패키지는 ${pkg.version}).\n centralu install`)
572
+ const where = PLATFORM === 'win32' ? WIN.dir : '/Applications'
573
+ console.log(`\nThe ${where} copy is ${version} (this package is ${pkg.version}).\n centralu install`)
293
574
  }
294
575
 
295
- /** 앱을 띄운 뒤에만 알린다 — 업데이트 확인 때문에 실행이 늦어지면 안 된다 */
576
+ /** Only announced after the app has launched — checking for updates must never delay startup. */
296
577
  async function notifyIfOutdated() {
297
- // 여기서는 실패를 **전부 삼킨다.** 앱을 띄우러 온 사람에게 레지스트리 사정을 말할 이유가 없다
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.
298
580
  const res = await latestVersion()
299
581
  if (res.ok && isNewer(res.version, pkg.version)) {
300
- console.log(`\n새 버전이 있습니다: ${pkg.version} → ${res.version}\n centralu update`)
582
+ console.log(`\nA new version is available: ${pkg.version} → ${res.version}\n centralu update`)
301
583
  }
302
584
  }
303
585
 
304
586
  const HELP = `${APP_NAME} ${pkg.version}
305
587
 
306
- centralu 앱을 실행합니다
307
- centralu install 앱 목록에 등록합니다 (macOS는 /Applications, Linux는 메뉴 항목)
308
- centralu uninstall 그 등록을 지웁니다 (대화 기록은 남습니다)
309
- centralu update 새 버전이 있으면 올립니다
310
- centralu --version 버전을 출력합니다
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
311
597
 
312
- 필요한 것: Node 22+, 그리고 claude 또는 codex CLI (앱 첫 화면이 상태를 알려줍니다)`
598
+ Requires: Node ${NODE_FLOOR}+, and the claude or codex CLI (the app's first screen reports the status)`
313
599
 
314
600
  const [cmd, ...rest] = process.argv.slice(2)
315
601
  switch (cmd) {
316
602
  case 'install':
317
- install()
603
+ install(rest.includes('--force'))
318
604
  break
319
605
  case 'uninstall':
320
606
  uninstall()
@@ -322,6 +608,67 @@ switch (cmd) {
322
608
  case 'update':
323
609
  await update()
324
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
+ }
325
672
  case '--version':
326
673
  case '-v':
327
674
  console.log(pkg.version)
@@ -331,8 +678,10 @@ switch (cmd) {
331
678
  case 'help':
332
679
  console.log(HELP)
333
680
  break
334
- default:
335
- run(cmd ? [cmd, ...rest] : [])
681
+ default: {
682
+ // Started before the update check, never after it: the check must not delay startup.
683
+ const launched = run(cmd ? [cmd, ...rest] : [])
336
684
  notifyIfCopyStale()
337
- await notifyIfOutdated()
685
+ await Promise.all([launched, notifyIfOutdated()])
686
+ }
338
687
  }