centralu 0.1.0-beta.1 → 0.1.0-beta.10

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,18 +1,48 @@
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.
14
+ *
15
+ * Linux is served from the same shim (issue #14). The two platforms disagree about what
16
+ * "the app" even is — macOS hands a `.app` directory to LaunchServices, Linux runs a
17
+ * self-contained AppImage itself — so that difference lives in one table (`TARGETS`)
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.
12
24
  */
13
25
  import { execFileSync, spawn } from 'node:child_process'
14
- import { existsSync, rmSync } from 'node:fs'
26
+ import { copyDiffers, isNewer } from './semver.mjs'
27
+ import {
28
+ busyMessage,
29
+ earlyExitMessage,
30
+ executableIn,
31
+ installedPaths,
32
+ isBusyError,
33
+ launchPlan,
34
+ npmCommand,
35
+ regExe,
36
+ shortcutCommand,
37
+ targetFor,
38
+ TARGETS,
39
+ webview2MissingMessage,
40
+ webview2Status,
41
+ windowsInstall,
42
+ } from './platform.mjs'
43
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
15
44
  import { createRequire } from 'node:module'
45
+ import { homedir } from 'node:os'
16
46
  import { dirname, join } from 'node:path'
17
47
 
18
48
  const require = createRequire(import.meta.url)
@@ -22,89 +52,307 @@ const APP_NAME = 'Centralu'
22
52
  const BUNDLE = `${APP_NAME}.app`
23
53
  const INSTALLED = `/Applications/${BUNDLE}`
24
54
 
25
- /** 아키텍처별 패키지 안의 `.app`. 못 찾으면 null */
55
+ const PLATFORM = process.platform
56
+ const HOME = homedir()
57
+
58
+ /** Where a menu entry for the app goes on freedesktop desktops */
59
+ const DESKTOP_ENTRY = join(HOME, '.local/share/applications/centralu.desktop')
60
+
61
+ /** Where `centralu install` copies the app on Windows (see `windowsInstall`) */
62
+ const WIN = PLATFORM === 'win32' ? windowsInstall(process.env, HOME) : null
63
+
64
+ /** The arch package for this machine (the table lives in platform.mjs) */
65
+ const TARGET = targetFor(PLATFORM, process.arch)
66
+
67
+ /** The app inside the architecture-specific package. null if it cannot be found. */
26
68
  function bundledApp() {
27
- for (const dep of ['centralu-darwin-arm64']) {
28
- try {
29
- return join(dirname(require.resolve(`${dep}/package.json`)), BUNDLE)
30
- } catch {
31
- // 이 아키텍처용 패키지가 안 깔린 것 — 아래에서 이유를 말한다
32
- }
69
+ if (!TARGET) return null
70
+ let root
71
+ try {
72
+ root = dirname(require.resolve(`${TARGET.pkg}/package.json`))
73
+ } catch {
74
+ return null // The package for this architecture is not installed — the reason is explained below.
33
75
  }
34
- return null
76
+ const app = join(root, TARGET.artifact)
77
+ return existsSync(executableIn(PLATFORM, app)) ? app : null
35
78
  }
36
79
 
37
80
  /**
38
- * 지금 이 맥에서 왜 못 쓰는지를 **구체적으로** 말한다.
39
- * "설치가 안 됐습니다"만 뜨면 사용자가 할 수 있는 일이 없다.
81
+ * States **specifically** why this machine cannot run it right now.
82
+ * If all that shows is "not installed", there is nothing the person can act on.
40
83
  */
41
84
  function explainMissing() {
42
- if (process.platform !== 'darwin') {
43
- return `${APP_NAME}는 macOS 앱입니다 (지금 플랫폼: ${process.platform}).`
44
- }
45
- if (process.arch !== 'arm64') {
85
+ if (!TARGET) {
86
+ // Kept as its own case: "not published for your machine" and "not published for
87
+ // Intel Macs, and here is the reason" send the user to different places.
88
+ if (PLATFORM === 'darwin') {
89
+ return (
90
+ `${APP_NAME} is Apple Silicon only for now (current architecture: ${process.arch}).\n` +
91
+ 'Intel Mac support needs native addons bundled in as well, and is tracked as separate work.'
92
+ )
93
+ }
94
+ const supported = Object.keys(TARGETS).join(', ')
46
95
  return (
47
- `${APP_NAME}는 아직 Apple Silicon 전용입니다 (지금 아키텍처: ${process.arch}).\n` +
48
- 'Intel 맥 지원은 네이티브 애드온까지 함께 묶어야 해서 별도 작업으로 잡혀 있습니다.'
96
+ `${APP_NAME} does not support ${PLATFORM}/${process.arch} yet.\n` +
97
+ `Currently supported combinations: ${supported}\n` +
98
+ 'Progress is tracked at https://github.com/ijun17/centralu/issues/14.'
49
99
  )
50
100
  }
51
101
  return (
52
- '앱 본체 패키지(centralu-darwin-arm64)를 찾지 못했습니다.\n' +
53
- '설치가 중간에 끊겼을 수 있습니다 — `npm i -g centralu`로 다시 설치해 주세요.'
102
+ `Could not find the app package (${TARGET.pkg}).\n` +
103
+ 'The install may have been interrupted — please reinstall with `npm i -g centralu`.'
54
104
  )
55
105
  }
56
106
 
57
107
  function requireApp() {
58
108
  const app = bundledApp()
59
- if (!app || !existsSync(app)) {
109
+ if (!app) {
60
110
  console.error(explainMissing())
61
111
  process.exit(1)
62
112
  }
63
113
  return app
64
114
  }
65
115
 
66
- /** 앱을 띄운다. `/Applications`에 설치돼 있으면 그쪽을 먼저 쓴다 */
67
- function run(args) {
68
- const app = existsSync(INSTALLED) ? INSTALLED : requireApp()
69
- // `open`은 LaunchServices를 거친다 — Dock 아이콘·단일 인스턴스가 그래야 제대로 산다
70
- const r = spawn('open', ['-a', app, ...(args.length ? ['--args', ...args] : [])], { stdio: 'inherit' })
116
+ /**
117
+ * Launches the app. If it is installed in `/Applications` (macOS) or
118
+ * `%LOCALAPPDATA%\Programs\Centralu` (Windows), that copy is used first.
119
+ *
120
+ * Linux has no such preference, and not for lack of an equivalent: `centralu install`
121
+ * on Linux writes a launcher that points back at this same package, so there is never a
122
+ * second copy to prefer.
123
+ *
124
+ * Resolves once the launcher has nothing more to wait for. Only Windows waits at all —
125
+ * briefly, to report an exe that dies on start (see runWindows).
126
+ */
127
+ async function run(args) {
128
+ if (PLATFORM === 'darwin') {
129
+ const app = existsSync(INSTALLED) ? INSTALLED : requireApp()
130
+ // `open` goes through LaunchServices — the dock icon and single-instance behavior only
131
+ // work correctly that way.
132
+ const plan = launchPlan(PLATFORM, app, args, HOME)
133
+ const r = spawn(plan.command, plan.args, plan.options)
134
+ r.on('exit', (code) => process.exit(code ?? 0))
135
+ return
136
+ }
137
+ if (PLATFORM === 'win32') return runWindows(args)
138
+ // Linux has no LaunchServices — the AppImage is its own launcher, so run it directly.
139
+ //
140
+ // Staying attached to the terminal is on purpose. The two usual first failures on
141
+ // Linux both explain themselves on stderr and nowhere else: a missing FUSE
142
+ // ("AppImages require FUSE to run", with the workaround in the same sentence) and a
143
+ // missing system library from the loader. Detaching would hand back the shell prompt
144
+ // and throw away the one line that says what to do.
145
+ const app = requireApp()
146
+ const plan = launchPlan(PLATFORM, app, args, HOME)
147
+ const r = spawn(plan.command, plan.args, plan.options)
148
+ r.on('error', (e) => {
149
+ console.error(`Failed to run ${app}: ${e.message}`)
150
+ // EACCES here means the executable bit did not survive the trip through npm, which
151
+ // is invisible from the message alone.
152
+ if (e.code === 'EACCES') console.error(`No execute permission — run \`chmod +x "${app}"\` then try again.`)
153
+ process.exit(1)
154
+ })
71
155
  r.on('exit', (code) => process.exit(code ?? 0))
72
156
  }
73
157
 
158
+ /** How long to watch a freshly started exe for an immediate exit */
159
+ const EARLY_EXIT_MS = 3000
160
+
161
+ /**
162
+ * Windows: check WebView2, start `centralu.exe` detached, and watch it for a few seconds.
163
+ *
164
+ * The app is a GUI program, so a failure to start has nowhere to print. Without WebView2
165
+ * the exe ends before it has a window, and the person sees nothing happen at all — the
166
+ * registry check turns that into a message with the download link before anything starts.
167
+ * The short watch afterwards catches whatever the registry cannot see (a broken runtime, a
168
+ * blocked exe), then lets go so the app outlives this launcher.
169
+ */
170
+ async function runWindows(args) {
171
+ const webview = webview2Status(process.env, queryRegistry)
172
+ if (webview.state === 'missing') {
173
+ console.error(webview2MissingMessage())
174
+ process.exit(1)
175
+ }
176
+ const app = existsSync(WIN.exe) ? WIN.dir : requireApp()
177
+ const plan = launchPlan(PLATFORM, app, args, HOME)
178
+ const child = spawn(plan.command, plan.args, plan.options)
179
+ await new Promise((resolve) => {
180
+ const done = () => {
181
+ clearTimeout(timer)
182
+ child.removeAllListeners()
183
+ child.unref()
184
+ resolve()
185
+ }
186
+ const timer = setTimeout(done, EARLY_EXIT_MS)
187
+ child.on('error', (e) => {
188
+ console.error(`Failed to start ${plan.command}: ${e.message}`)
189
+ process.exitCode = 1
190
+ done()
191
+ })
192
+ child.on('exit', (code) => {
193
+ // 0 is a normal answer this early: a second start hands over to the window that is
194
+ // already open and leaves.
195
+ if (code !== 0 && code !== null) {
196
+ console.error(earlyExitMessage(code, HOME))
197
+ process.exitCode = 1
198
+ }
199
+ done()
200
+ })
201
+ })
202
+ }
203
+
204
+ /**
205
+ * `reg query <key> /v pv`: stdout, null when the key or value does not exist (reg exits 1),
206
+ * undefined when reg itself could not run.
207
+ */
208
+ function queryRegistry(key) {
209
+ try {
210
+ return execFileSync(regExe(process.env), ['query', key, '/v', 'pv'], {
211
+ encoding: 'utf8',
212
+ stdio: ['ignore', 'pipe', 'ignore'],
213
+ windowsHide: true,
214
+ })
215
+ } catch (e) {
216
+ return typeof e?.status === 'number' ? null : undefined
217
+ }
218
+ }
219
+
74
220
  /**
75
- * `/Applications`에 복사한다.
221
+ * Registers the app in the system menu.
76
222
  *
77
- * **postinstall로 몰래 하지 않는다.** 남의 `/Applications`에 조용히 쓰는 것은 신뢰를 깎고,
78
- * pnpm은 postinstall을 기본 차단한다. 사용자가 원할 때 명시적으로 부르게 한다.
223
+ * **Never done quietly through postinstall.** Writing silently into someone's `/Applications`
224
+ * erodes trust, and pnpm blocks postinstall by default anyway. This is only run when the
225
+ * person explicitly asks for it.
79
226
  */
80
227
  function install() {
81
228
  const app = requireApp()
229
+ if (PLATFORM === 'win32') return installWindows(app)
230
+ if (PLATFORM !== 'darwin') return installDesktopEntry(app)
82
231
  if (existsSync(INSTALLED)) {
83
- console.log(`기존 ${INSTALLED}를 새 버전으로 교체합니다.`)
232
+ console.log(`Replacing the existing ${INSTALLED} with the new version.`)
84
233
  rmSync(INSTALLED, { recursive: true, force: true })
85
234
  }
86
- // cp가 아니라 ditto — 번들의 권한·확장속성을 그대로 옮긴다 (서명이 깨지지 않게)
235
+ // ditto, not cp — carries over the bundle's permissions and extended attributes unchanged
236
+ // (so the signature does not break).
87
237
  execFileSync('/usr/bin/ditto', [app, INSTALLED], { stdio: 'inherit' })
88
- console.log(`설치했습니다: ${INSTALLED}`)
89
- console.log('이제 Launchpad·Spotlight에서도 찾을 수 있습니다.')
238
+ console.log(`Installed: ${INSTALLED}`)
239
+ console.log('It can now be found in Launchpad and Spotlight too.')
240
+ }
241
+
242
+ /**
243
+ * The Linux answer to "copy it into /Applications so it shows up in Launchpad".
244
+ *
245
+ * It writes a launcher, not a copy. On macOS the `.app` has to move because
246
+ * LaunchServices only indexes a few directories; on Linux the menu indexes `.desktop`
247
+ * files that may point anywhere, so pointing at the npm package leaves exactly one copy
248
+ * of the binary — and `npm i -g centralu@newer` then updates the menu entry too,
249
+ * because the path it names does not change.
250
+ */
251
+ function installDesktopEntry(app) {
252
+ const icon = join(dirname(app), 'icon.png')
253
+ const entry = [
254
+ '[Desktop Entry]',
255
+ 'Type=Application',
256
+ `Name=${APP_NAME}`,
257
+ 'Comment=Run, watch and steer several coding agents in one window',
258
+ // Quoted because the path runs through node_modules and npm prefixes can contain spaces
259
+ `Exec="${app}" %U`,
260
+ `Icon=${existsSync(icon) ? icon : 'centralu'}`,
261
+ 'Terminal=false',
262
+ 'Categories=Development;Utility;',
263
+ // Guessed, not measured: this must equal the WM class the running window reports,
264
+ // and we have no Linux desktop here to read it off. If the taskbar shows two icons
265
+ // for one window, this line is why.
266
+ 'StartupWMClass=Centralu',
267
+ '',
268
+ ].join('\n')
269
+ mkdirSync(dirname(DESKTOP_ENTRY), { recursive: true })
270
+ writeFileSync(DESKTOP_ENTRY, entry)
271
+ chmodSync(DESKTOP_ENTRY, 0o755)
272
+ console.log(`Registered: ${DESKTOP_ENTRY}`)
273
+ console.log('It can now be found in the app list too (some desktops require logging back in before it shows up).')
274
+ }
275
+
276
+ /**
277
+ * The Windows answer to "copy it into /Applications": copy the folder into
278
+ * `%LOCALAPPDATA%\Programs\Centralu` and put a shortcut in the Start menu.
279
+ *
280
+ * A copy rather than a shortcut into the npm package (the Linux shape), because Windows
281
+ * will not replace a running program's files: with the app running from the package,
282
+ * `npm i -g centralu@newer` fails. Running from the copy leaves the package free to update.
283
+ *
284
+ * The old copy is never deleted before the new one is complete. The new folder is
285
+ * assembled beside it, the old one is renamed aside, and only then is the new one moved in
286
+ * — so a copy that fails halfway, or an app that is still running, leaves the working
287
+ * install untouched rather than a folder with half its files.
288
+ */
289
+ function installWindows(app) {
290
+ const staging = `${WIN.dir}.new`
291
+ const aside = `${WIN.dir}.old-${Date.now()}`
292
+ rmSync(staging, { recursive: true, force: true })
293
+ mkdirSync(dirname(WIN.dir), { recursive: true })
294
+ cpSync(app, staging, { recursive: true })
295
+ writeFileSync(join(staging, 'centralu-version.txt'), `${pkg.version}\n`)
296
+ if (existsSync(WIN.dir)) {
297
+ console.log(`Replacing the existing ${WIN.dir} with the new version.`)
298
+ try {
299
+ renameSync(WIN.dir, aside)
300
+ } catch (e) {
301
+ rmSync(staging, { recursive: true, force: true })
302
+ if (!isBusyError(e)) throw e
303
+ console.error(busyMessage(WIN.dir))
304
+ process.exit(1)
305
+ }
306
+ }
307
+ renameSync(staging, WIN.dir)
308
+ try {
309
+ rmSync(aside, { recursive: true, force: true })
310
+ } catch {
311
+ // A rename can succeed while the old copy is running; its files then stay locked until
312
+ // it quits. Harmless — it is no longer the one anything starts — but worth a line.
313
+ console.log(`The previous copy is still in use and was left at ${aside}. Delete it after quitting ${APP_NAME}.`)
314
+ }
315
+ const sc = shortcutCommand(process.env, { shortcut: WIN.shortcut, target: WIN.exe, workdir: HOME })
316
+ try {
317
+ execFileSync(sc.command, sc.args, { env: sc.env, stdio: ['ignore', 'ignore', 'inherit'], windowsHide: true })
318
+ } catch (e) {
319
+ console.log(`Installed: ${WIN.dir}`)
320
+ console.error(`Could not create the Start menu shortcut (${e.message}). Start ${WIN.exe} directly, or run \`centralu\`.`)
321
+ process.exit(1)
322
+ }
323
+ console.log(`Installed: ${WIN.dir}`)
324
+ console.log(`Start menu: ${WIN.shortcut}`)
325
+ console.log('It can now be found in Start and Windows search, and `centralu` starts this copy too.')
90
326
  }
91
327
 
92
328
  function uninstall() {
93
- if (!existsSync(INSTALLED)) {
94
- console.log(`${INSTALLED}가 없습니다 — 지울 것이 없습니다.`)
329
+ const paths = installedPaths(PLATFORM, process.env, HOME)
330
+ const present = paths.filter((p) => existsSync(p))
331
+ if (present.length === 0) {
332
+ console.log(`${paths[0]} does not exist — nothing to remove.`)
95
333
  return
96
334
  }
97
- rmSync(INSTALLED, { recursive: true, force: true })
98
- console.log(`지웠습니다: ${INSTALLED}`)
99
- console.log('패키지 자체를 지우려면: npm uninstall -g centralu')
100
- console.log(`대화 기록은 그대로 남아 있습니다 (~/.control-center). 지우려면 직접 지우세요.`)
335
+ for (const installed of present) {
336
+ try {
337
+ rmSync(installed, { recursive: true, force: true })
338
+ } catch (e) {
339
+ if (PLATFORM !== 'win32' || !isBusyError(e)) throw e
340
+ console.error(busyMessage(installed))
341
+ process.exit(1)
342
+ }
343
+ console.log(`Removed: ${installed}`)
344
+ }
345
+ console.log('To remove the package itself: npm uninstall -g centralu')
346
+ const data = PLATFORM === 'win32' ? '%USERPROFILE%\\.centralu' : '~/.centralu'
347
+ console.log(`Conversation history is left in place (${data}). Remove it yourself if you want to.`)
101
348
  }
102
349
 
103
350
  /**
104
- * npm 레지스트리가 곧 업데이트 채널이다 — 서버도 서명 키도 따로 필요 없다.
351
+ * The npm registry itself is the update channel — no separate server or signing key needed.
105
352
  *
106
- * 실패를 뭉뚱그리지 않는다. 못 닿은 것(네트워크)과 없는 것(404)은 **사용자가 할 일이 다르다** —
107
- * 앞은 연결을 보라는 말이고, 뒤는 봐도 소용없다는 말이다.
353
+ * Failures are not lumped together. Being unreachable (network) and not existing (404) call
354
+ * for **different actions from the person** — the first says to check the connection, the
355
+ * second says checking will not help.
108
356
  */
109
357
  async function latestVersion(timeoutMs = 2000) {
110
358
  try {
@@ -117,68 +365,128 @@ async function latestVersion(timeoutMs = 2000) {
117
365
  const version = body?.version
118
366
  return typeof version === 'string' ? { ok: true, version } : { ok: false, reason: 'bad-response' }
119
367
  } catch {
120
- return { ok: false, reason: 'network' } // 네트워크가 없어도 앱은 떠야 한다
368
+ return { ok: false, reason: 'network' } // The app has to launch even with no network.
121
369
  }
122
370
  }
123
371
 
124
- /** `1.2.10` > `1.2.9` — 문자열 비교로는 틀린다 */
125
- function isNewer(a, b) {
126
- const pa = String(a).split('.').map(Number)
127
- const pb = String(b).split('.').map(Number)
128
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
129
- const x = pa[i] ?? 0
130
- const y = pb[i] ?? 0
131
- if (x !== y) return x > y
132
- }
133
- return false
134
- }
135
372
 
136
373
  async function update() {
137
374
  const res = await latestVersion(8000)
138
375
  if (!res.ok) {
139
376
  if (res.reason === 'missing') {
140
- console.error(`레지스트리에 ${pkg.name}가 없습니다. 이름이 바뀌었거나 아직 발행 전입니다.`)
141
- console.error('https://github.com/ijun17/centralu/releases 를 확인해 주세요.')
377
+ console.error(`${pkg.name} is not on the registry. The name may have changed, or it has not been published yet.`)
378
+ console.error('Please check https://github.com/ijun17/centralu/releases.')
142
379
  } else if (res.reason === 'network') {
143
- console.error('레지스트리에 닿지 못했습니다. 네트워크를 확인하고 다시 시도해 주세요.')
380
+ console.error('Could not reach the registry. Please check your network and try again.')
144
381
  } else {
145
- console.error(`레지스트리가 예상 밖의 응답을 했습니다 (${res.reason}).`)
382
+ console.error(`The registry gave an unexpected response (${res.reason}).`)
146
383
  }
147
384
  process.exit(1)
148
385
  }
149
386
  const latest = res.version
150
387
  if (!isNewer(latest, pkg.version)) {
151
- console.log(`이미 최신입니다 (${pkg.version}).`)
388
+ console.log(`Already up to date (${pkg.version}).`)
389
+ // The package can be up to date while the copy is not — that is exactly the state of
390
+ // someone who upgraded with `npm i -g`.
391
+ notifyIfCopyStale()
152
392
  return
153
393
  }
154
- console.log(`${pkg.version} → ${latest} 로 올립니다.`)
155
- execFileSync('npm', ['i', '-g', `${pkg.name}@${latest}`], { stdio: 'inherit' })
156
- // /Applications에 넣어둔 사람은 그쪽도 함께 갱신해야 옛 버전이 남지 않는다
157
- if (existsSync(INSTALLED)) {
158
- console.log(`${INSTALLED}도 갱신합니다.`)
159
- execFileSync(process.argv[1], ['install'], { stdio: 'inherit' })
394
+ console.log(`Upgrading ${pkg.version} → ${latest}.`)
395
+ const npm = npmCommand(PLATFORM, ['i', '-g', `${pkg.name}@${latest}`])
396
+ try {
397
+ execFileSync(npm.command, npm.args, npm.options)
398
+ } catch (e) {
399
+ // On Windows the usual cause is the app running from inside the package: its files are
400
+ // locked, and npm cannot replace them. npm's own output (above) has the details.
401
+ if (PLATFORM === 'win32') console.error(`\n${busyMessage('the npm package')}`)
402
+ process.exit(typeof e?.status === 'number' ? e.status : 1)
403
+ }
404
+ // Someone with a copy in /Applications (or %LOCALAPPDATA%\Programs) needs that updated
405
+ // too, or the old version stays behind.
406
+ // On Linux the menu entry points at the package instead of a copy, so rewriting it is
407
+ // cheap — but it is still worth doing, because the Exec path is what would go stale.
408
+ //
409
+ // The *new* launcher does the refresh: this file has just been replaced on disk. It is
410
+ // run through `node` rather than as a script, because Windows cannot execute a `.mjs`.
411
+ const installed = installedPaths(PLATFORM, process.env, HOME)[0]
412
+ if (existsSync(installed)) {
413
+ console.log(`Updating ${installed} too.`)
414
+ execFileSync(process.execPath, [process.argv[1], 'install'], { stdio: 'inherit' })
415
+ }
416
+ console.log('Done. If the app is open, please restart it.')
417
+ }
418
+
419
+ /**
420
+ * The version of the copy placed in /Applications. null if there is nothing to compare.
421
+ *
422
+ * `defaults` is used because there is no guarantee Info.plist stays XML — Tauri just happens
423
+ * to write it as XML today, and if it ever switches to a binary plist, anything reading it
424
+ * with a regex would silently stop working. `defaults` is always present on a Mac and reads
425
+ * both formats.
426
+ *
427
+ * On Windows it is the file `centralu install` wrote beside the copy (see `windowsInstall`
428
+ * for why not the exe's own version).
429
+ */
430
+ function installedCopyVersion() {
431
+ if (PLATFORM === 'win32') {
432
+ try {
433
+ return readFileSync(WIN.versionFile, 'utf8').trim()
434
+ } catch {
435
+ return null
436
+ }
437
+ }
438
+ if (PLATFORM !== 'darwin' || !existsSync(INSTALLED)) return null
439
+ try {
440
+ const out = execFileSync('/usr/bin/defaults', ['read', join(INSTALLED, 'Contents/Info.plist'), 'CFBundleShortVersionString'], {
441
+ encoding: 'utf8',
442
+ stdio: ['ignore', 'pipe', 'ignore'],
443
+ })
444
+ return out.trim()
445
+ } catch {
446
+ // Failing to read this is not worth mentioning — the app has already launched, and there
447
+ // is nothing useful to say here.
448
+ return null
160
449
  }
161
- console.log('끝났습니다. 앱이 떠 있다면 다시 시작해 주세요.')
162
450
  }
163
451
 
164
- /** 앱을 띄운 뒤에만 알린다 — 업데이트 확인 때문에 실행이 늦어지면 안 된다 */
452
+ /**
453
+ * One line, if the package and the /Applications copy have drifted apart.
454
+ *
455
+ * `npm i -g centralu` only changes the package. So a state quietly forms where the launcher is
456
+ * new but the app that opens from Spotlight is old — this actually happened (beta.1 stayed in
457
+ * /Applications while only the package moved to beta.3), and nothing on screen said so.
458
+ *
459
+ * **Says it, does not do it.** The same rule as the in-app update line. Overwriting someone's
460
+ * /Applications without asking is the exact reason this package keeps `install` as a separate
461
+ * step.
462
+ */
463
+ function notifyIfCopyStale() {
464
+ const version = installedCopyVersion()
465
+ if (!copyDiffers(pkg.version, version)) return
466
+ const where = PLATFORM === 'win32' ? WIN.dir : '/Applications'
467
+ console.log(`\nThe ${where} copy is ${version} (this package is ${pkg.version}).\n centralu install`)
468
+ }
469
+
470
+ /** Only announced after the app has launched — checking for updates must never delay startup. */
165
471
  async function notifyIfOutdated() {
166
- // 여기서는 실패를 **전부 삼킨다.** 앱을 띄우러 온 사람에게 레지스트리 사정을 말할 이유가 없다
472
+ // Every failure here is **swallowed entirely.** Someone who came here to launch the app has
473
+ // no reason to be told about registry troubles.
167
474
  const res = await latestVersion()
168
475
  if (res.ok && isNewer(res.version, pkg.version)) {
169
- console.log(`\n새 버전이 있습니다: ${pkg.version} → ${res.version}\n centralu update`)
476
+ console.log(`\nA new version is available: ${pkg.version} → ${res.version}\n centralu update`)
170
477
  }
171
478
  }
172
479
 
173
480
  const HELP = `${APP_NAME} ${pkg.version}
174
481
 
175
- centralu 앱을 실행합니다
176
- centralu install /Applications에 복사합니다 (Launchpad·Spotlight에 뜨게)
177
- centralu uninstall /Applications에서 지웁니다 (대화 기록은 남습니다)
178
- centralu update 새 버전이 있으면 올립니다
179
- centralu --version 버전을 출력합니다
482
+ centralu Launches the app
483
+ centralu install Registers it in the app list (macOS: /Applications, Linux: a menu entry,
484
+ Windows: %LOCALAPPDATA%\\Programs\\Centralu and a Start menu shortcut)
485
+ centralu uninstall Removes that registration (conversation history is kept)
486
+ centralu update Upgrades if a new version is available
487
+ centralu --version Prints the version
180
488
 
181
- 필요한 것: Node 22+, 그리고 claude 또는 codex CLI (앱 첫 화면이 상태를 알려줍니다)`
489
+ Requires: Node 22+, and the claude or codex CLI (the app's first screen reports the status)`
182
490
 
183
491
  const [cmd, ...rest] = process.argv.slice(2)
184
492
  switch (cmd) {
@@ -200,7 +508,10 @@ switch (cmd) {
200
508
  case 'help':
201
509
  console.log(HELP)
202
510
  break
203
- default:
204
- run(cmd ? [cmd, ...rest] : [])
205
- await notifyIfOutdated()
511
+ default: {
512
+ // Started before the update check, never after it: the check must not delay startup.
513
+ const launched = run(cmd ? [cmd, ...rest] : [])
514
+ notifyIfCopyStale()
515
+ await Promise.all([launched, notifyIfOutdated()])
516
+ }
206
517
  }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * The launcher's per-platform decisions, as plain functions of the platform.
3
+ *
4
+ * Lives apart from `centralu.mjs` for the same reason `semver.mjs` does: the launcher runs
5
+ * a command at import time, so a test cannot import it. Everything here takes the platform
6
+ * (and the environment, and the home folder) as an argument instead of reading `process`,
7
+ * so `tooling/launcher-platform.test.ts` can ask what Windows would do from a Mac — which
8
+ * is the only place this project can run a test before the Windows package ships.
9
+ *
10
+ * package.json "files" lists the whole bin/ directory, so this ships automatically.
11
+ */
12
+ import { posix, win32 } from 'node:path'
13
+
14
+ export const APP_NAME = 'Centralu'
15
+
16
+ /**
17
+ * The arch package for each machine, and what the app is called inside it.
18
+ *
19
+ * `artifact` must match `scripts/release-npm.mts` exactly — that script renames what
20
+ * Tauri produced (`Centralu_0.1.0-beta.2_amd64.AppImage`) to a fixed name precisely so
21
+ * the launcher does not have to know version or architecture. If the two ever disagree
22
+ * the symptom is "installed fine, does nothing".
23
+ *
24
+ * Only combinations that are actually published belong here. Listing a package that was
25
+ * never released would turn "not supported yet" into "your install is broken", which
26
+ * sends the user off to reinstall something that can never appear.
27
+ * `tooling/launcher-platform.test.ts` holds this table to the shim's `optionalDependencies`.
28
+ *
29
+ * Windows ships a folder, not a single file: `Centralu\centralu.exe` beside
30
+ * `Centralu\resources\host\`. Tauri looks for its resources next to the exe when nothing
31
+ * is installed, so the folder runs where npm unpacked it (#14, W1 in #307).
32
+ */
33
+ export const TARGETS = {
34
+ 'darwin-arm64': { pkg: '@centralu/darwin-arm64', artifact: `${APP_NAME}.app` },
35
+ 'linux-arm64': { pkg: '@centralu/linux-arm64', artifact: `${APP_NAME}.AppImage` },
36
+ 'linux-x64': { pkg: '@centralu/linux-x64', artifact: `${APP_NAME}.AppImage` },
37
+ 'win32-x64': { pkg: '@centralu/win32-x64', artifact: APP_NAME },
38
+ }
39
+
40
+ /** The target for a platform/arch pair, or null when nothing is published for it. */
41
+ export function targetFor(platform, arch) {
42
+ return TARGETS[`${platform}-${arch}`] ?? null
43
+ }
44
+
45
+ /**
46
+ * The file that proves the artifact is there and is what gets started.
47
+ *
48
+ * On macOS and Linux that is the artifact itself (a bundle handed to `open`, an AppImage
49
+ * run directly). On Windows the artifact is a folder, and a folder that exists without its
50
+ * exe — an interrupted extract — must count as missing, not as found.
51
+ */
52
+ export function executableIn(platform, app) {
53
+ return platform === 'win32' ? win32.join(app, 'centralu.exe') : app
54
+ }
55
+
56
+ /**
57
+ * How to start the app: the command, its arguments, the spawn options, and whether the
58
+ * launcher stays attached until it exits.
59
+ *
60
+ * - macOS goes through `open`, so LaunchServices handles the dock icon and single instance.
61
+ * - Linux runs the AppImage attached to the terminal, because its two usual first failures
62
+ * (missing FUSE, a missing system library) explain themselves on stderr and nowhere else.
63
+ * - Windows starts `centralu.exe` **detached**. That is not only about handing the prompt
64
+ * back: libuv puts every non-detached child in a job object that kills it when the parent
65
+ * exits, so an attached app would die the moment this launcher returns, or when the
66
+ * console window is closed. `centralu.exe` is a GUI-subsystem program (checked by
67
+ * `scripts/release-npm.mts`), so no console window appears for it either way.
68
+ * `windowsHide` stays off on purpose: it starts the process with SW_HIDE, which a GUI
69
+ * program applies to its first window — the app would start invisible.
70
+ * The working directory is the home folder, because a process holds its working
71
+ * directory open on Windows, and that would stop the shell's folder from being renamed
72
+ * or deleted (and `centralu install` from replacing the app's own folder).
73
+ */
74
+ export function launchPlan(platform, app, args, home) {
75
+ if (platform === 'darwin') {
76
+ return {
77
+ command: 'open',
78
+ args: ['-a', app, ...(args.length ? ['--args', ...args] : [])],
79
+ options: { stdio: 'inherit' },
80
+ attached: true,
81
+ }
82
+ }
83
+ if (platform === 'win32') {
84
+ return {
85
+ command: executableIn(platform, app),
86
+ args,
87
+ options: { detached: true, stdio: 'ignore', windowsHide: false, cwd: home },
88
+ attached: false,
89
+ }
90
+ }
91
+ return { command: app, args, options: { stdio: 'inherit' }, attached: true }
92
+ }
93
+
94
+ /**
95
+ * Where `centralu install` puts the app on Windows, and what it writes there.
96
+ *
97
+ * The analogue of copying the bundle into `/Applications` on macOS, not of the Linux menu
98
+ * entry that points back into the npm package. The reason is Windows-specific: a running
99
+ * program's files cannot be replaced, so while the app runs from inside the npm package,
100
+ * `npm i -g centralu@newer` fails with EBUSY. A copy elsewhere is what lets the package
101
+ * update while the app is open.
102
+ *
103
+ * `%LOCALAPPDATA%\Programs` is the per-user program folder (VS Code's user installer and
104
+ * winget's user scope use it). No admin rights, nothing outside the user's own profile.
105
+ * The Start menu entry is a shortcut in the per-user Start menu folder; Windows search
106
+ * indexes it.
107
+ *
108
+ * `versionFile` records which package version the copy came from, so the launcher can say
109
+ * when the copy is behind — the exe's own version resource does not carry a prerelease tag.
110
+ */
111
+ export function windowsInstall(env, home) {
112
+ const local = env.LOCALAPPDATA || win32.join(home, 'AppData', 'Local')
113
+ const roaming = env.APPDATA || win32.join(home, 'AppData', 'Roaming')
114
+ const dir = win32.join(local, 'Programs', APP_NAME)
115
+ return {
116
+ dir,
117
+ shortcut: win32.join(roaming, 'Microsoft', 'Windows', 'Start Menu', 'Programs', `${APP_NAME}.lnk`),
118
+ exe: win32.join(dir, 'centralu.exe'),
119
+ versionFile: win32.join(dir, 'centralu-version.txt'),
120
+ }
121
+ }
122
+
123
+ /**
124
+ * What `centralu install` creates, per platform. `uninstall` removes exactly these and
125
+ * `update` refreshes them when the first one exists.
126
+ */
127
+ export function installedPaths(platform, env, home) {
128
+ if (platform === 'darwin') return [`/Applications/${APP_NAME}.app`]
129
+ if (platform === 'win32') {
130
+ const w = windowsInstall(env, home)
131
+ return [w.dir, w.shortcut]
132
+ }
133
+ return [posix.join(home, '.local/share/applications/centralu.desktop')]
134
+ }
135
+
136
+ function systemRoot(env) {
137
+ return env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows'
138
+ }
139
+
140
+ /**
141
+ * A PowerShell invocation that writes the Start-menu shortcut.
142
+ *
143
+ * Windows has no file format for a shortcut that Node can write; `.lnk` is a binary shell
144
+ * format, and the supported way to write one is the WScript.Shell COM object. The paths go
145
+ * in through environment variables rather than the script text, so a profile path with a
146
+ * space or an apostrophe (`C:\Users\O'Brien Kim\…`) needs no quoting at all — the one way
147
+ * this cannot be got wrong. PowerShell is named by its full path under `%SystemRoot%` so a
148
+ * `powershell.exe` earlier on PATH (or in the current folder) is never the one that runs.
149
+ */
150
+ export function shortcutCommand(env, { shortcut, target, workdir }) {
151
+ const script = [
152
+ '$ErrorActionPreference = "Stop"',
153
+ 'New-Item -ItemType Directory -Force -Path (Split-Path -Parent $env:CENTRALU_SHORTCUT) | Out-Null',
154
+ '$s = (New-Object -ComObject WScript.Shell).CreateShortcut($env:CENTRALU_SHORTCUT)',
155
+ '$s.TargetPath = $env:CENTRALU_TARGET',
156
+ '$s.WorkingDirectory = $env:CENTRALU_WORKDIR',
157
+ '$s.IconLocation = "$env:CENTRALU_TARGET,0"',
158
+ `$s.Description = "${APP_NAME}"`,
159
+ '$s.Save()',
160
+ ].join('; ')
161
+ return {
162
+ command: win32.join(systemRoot(env), 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
163
+ args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
164
+ env: { ...env, CENTRALU_SHORTCUT: shortcut, CENTRALU_TARGET: target, CENTRALU_WORKDIR: workdir },
165
+ }
166
+ }
167
+
168
+ /**
169
+ * How to run npm from the launcher (`centralu update`).
170
+ *
171
+ * On Windows `npm` is `npm.cmd`, and Node refuses to start a `.cmd` without a shell
172
+ * (EINVAL, since the April 2024 security releases) — and without one it would not find it
173
+ * either, as libuv only tries `.com` and `.exe`. So Windows goes through the shell, and
174
+ * because a shell re-parses its command line, every argument has to be a plain token. The
175
+ * version comes from the registry's response, so that is checked rather than trusted.
176
+ */
177
+ export function npmCommand(platform, args) {
178
+ for (const a of args) {
179
+ if (!/^[\w@./:+-]+$/.test(a)) throw new Error(`refusing to pass an unexpected argument to npm: ${JSON.stringify(a)}`)
180
+ }
181
+ return platform === 'win32'
182
+ ? { command: 'npm', args, options: { stdio: 'inherit', shell: true } }
183
+ : { command: 'npm', args, options: { stdio: 'inherit' } }
184
+ }
185
+
186
+ export const WEBVIEW2_DOWNLOAD = 'https://developer.microsoft.com/microsoft-edge/webview2/'
187
+ /** Microsoft's link to the Evergreen Bootstrapper itself, for someone who wants one click. */
188
+ export const WEBVIEW2_BOOTSTRAPPER = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703'
189
+
190
+ /** The Evergreen WebView2 Runtime's client id, the same under every registry hive. */
191
+ const WEBVIEW2_CLIENT = '{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}'
192
+
193
+ /**
194
+ * Where an installed WebView2 Runtime registers itself: per machine (the 32-bit view on
195
+ * 64-bit Windows, which is where the installer writes), per machine on 32-bit Windows, and
196
+ * per user. This is Microsoft's documented detection
197
+ * (learn.microsoft.com/microsoft-edge/webview2/concepts/distribution, "Detect if a WebView2
198
+ * Runtime is already installed").
199
+ */
200
+ export const WEBVIEW2_KEYS = [
201
+ `HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\${WEBVIEW2_CLIENT}`,
202
+ `HKLM\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\${WEBVIEW2_CLIENT}`,
203
+ `HKCU\\Software\\Microsoft\\EdgeUpdate\\Clients\\${WEBVIEW2_CLIENT}`,
204
+ ]
205
+
206
+ /** `reg.exe` by full path, for the same reason PowerShell is. */
207
+ export function regExe(env) {
208
+ return win32.join(systemRoot(env), 'System32', 'reg.exe')
209
+ }
210
+
211
+ /**
212
+ * The `pv` value out of `reg query <key> /v pv`, or null. Microsoft's rule: the runtime is
213
+ * installed when `pv` exists and is neither empty nor `0.0.0.0` (an uninstall can leave
214
+ * the key behind with that value).
215
+ */
216
+ export function parseRegPv(stdout) {
217
+ const m = /^\s*pv\s+REG_SZ\s+(\S*)\s*$/im.exec(String(stdout ?? ''))
218
+ const v = m?.[1] ?? ''
219
+ return v && v !== '0.0.0.0' ? v : null
220
+ }
221
+
222
+ /**
223
+ * Whether WebView2 is there, from a `query(key)` that returns `reg`'s stdout, `null` when
224
+ * the key does not exist, or `undefined` when `reg` itself could not run.
225
+ *
226
+ * Three answers, not two. "Missing" stops the launch with a message, so it has to be a
227
+ * positive finding — every hive was readable and none has the runtime. If `reg` could not
228
+ * run at all the answer is "unknown" and the app starts anyway: refusing to launch because
229
+ * a check broke would be worse than the blank window the check exists to explain.
230
+ *
231
+ * `WEBVIEW2_BROWSER_EXECUTABLE_FOLDER` points WebView2 at a fixed-version runtime that the
232
+ * registry knows nothing about; when it is set, the person has made that choice.
233
+ */
234
+ export function webview2Status(env, query) {
235
+ if (env.WEBVIEW2_BROWSER_EXECUTABLE_FOLDER) return { state: 'present', version: 'fixed' }
236
+ let unknown = false
237
+ for (const key of WEBVIEW2_KEYS) {
238
+ const out = query(key)
239
+ if (out === undefined) {
240
+ unknown = true
241
+ continue
242
+ }
243
+ const version = parseRegPv(out)
244
+ if (version) return { state: 'present', version }
245
+ }
246
+ return { state: unknown ? 'unknown' : 'missing' }
247
+ }
248
+
249
+ export function webview2MissingMessage() {
250
+ return (
251
+ `${APP_NAME} needs the Microsoft Edge WebView2 Runtime, and it is not installed on this machine.\n` +
252
+ 'Windows 11 includes it; some Windows 10 installs do not.\n' +
253
+ `Install the "Evergreen Bootstrapper" from ${WEBVIEW2_DOWNLOAD}\n` +
254
+ `(direct download: ${WEBVIEW2_BOOTSTRAPPER}),\n` +
255
+ 'then run `centralu` again.'
256
+ )
257
+ }
258
+
259
+ /**
260
+ * What to say when the exe exits nonzero within moments of starting.
261
+ *
262
+ * The launcher is detached by then, so this is the only place a failure to start can be
263
+ * reported at all — a GUI program has no stderr to read. The registry check catches the
264
+ * usual cause before the start; this catches the rest (a runtime that is registered but
265
+ * broken, a blocked exe, a crash before the window).
266
+ */
267
+ export function earlyExitMessage(code, home) {
268
+ return (
269
+ `${APP_NAME} exited right after starting (exit code ${code}).\n` +
270
+ `- If no window appeared, check the WebView2 Runtime: ${WEBVIEW2_DOWNLOAD}\n` +
271
+ `- The host log is ${win32.join(home, '.centralu', 'host.log')}\n` +
272
+ 'Please report it at https://github.com/ijun17/centralu/issues/14 with that log.'
273
+ )
274
+ }
275
+
276
+ /**
277
+ * What to say when Windows refuses to replace or remove the app's files.
278
+ *
279
+ * EBUSY / EPERM on a program folder almost always means the program is running: Windows
280
+ * locks a running exe and the native modules the host loaded. Macs and Linux let both be
281
+ * replaced underneath a running process, which is why this message exists only here.
282
+ */
283
+ export function busyMessage(path) {
284
+ return (
285
+ `Windows would not replace ${path} — ${APP_NAME} is probably still running.\n` +
286
+ 'Quit it (close the window and choose Quit), then run the command again.'
287
+ )
288
+ }
289
+
290
+ export function isBusyError(e) {
291
+ return e?.code === 'EBUSY' || e?.code === 'EPERM' || e?.code === 'EACCES' || e?.code === 'ENOTEMPTY'
292
+ }
package/bin/semver.mjs ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Version comparison for the launcher's update check.
3
+ *
4
+ * Lives in its own file so it can be tested (tooling/launcher-semver.test.ts):
5
+ * the launcher itself runs commands at import time and cannot be imported by a
6
+ * test. package.json "files" lists the whole bin/ directory, so this ships
7
+ * automatically — but note npm silently drops anything "files" does not cover,
8
+ * and an unshipped import only crashes on users' machines.
9
+ *
10
+ * Why not `.split('.').map(Number)`: that turns `0.1.0-beta.2` into
11
+ * [0, 1, NaN, 2], and `NaN !== NaN` ends the comparison at the third slot with
12
+ * `NaN > NaN` = false — so no beta ever saw the next beta. That is exactly how
13
+ * `centralu update` on 0.1.0-beta.1 answered "Already up to date" while
14
+ * beta.2 sat on the registry (found the day beta.2 shipped).
15
+ */
16
+ export function isNewer(a, b) {
17
+ const [coreA, preA] = split(String(a))
18
+ const [coreB, preB] = split(String(b))
19
+ for (let i = 0; i < 3; i++) {
20
+ const x = coreA[i] ?? 0
21
+ const y = coreB[i] ?? 0
22
+ if (x !== y) return x > y
23
+ }
24
+ // Same core: a release outranks any prerelease of it (1.0.0 > 1.0.0-beta.9).
25
+ if (preA === null && preB === null) return false
26
+ if (preA === null) return true
27
+ if (preB === null) return false
28
+ // Both prereleases: compare dot-separated identifiers, numeric ones as
29
+ // numbers (semver: numeric identifiers always rank below alphanumeric).
30
+ const ia = preA.split('.')
31
+ const ib = preB.split('.')
32
+ for (let i = 0; i < Math.max(ia.length, ib.length); i++) {
33
+ const x = ia[i]
34
+ const y = ib[i]
35
+ if (x === undefined) return false // shorter prerelease ranks lower
36
+ if (y === undefined) return true
37
+ const nx = /^\d+$/.test(x) ? Number(x) : null
38
+ const ny = /^\d+$/.test(y) ? Number(y) : null
39
+ if (nx !== null && ny !== null) {
40
+ if (nx !== ny) return nx > ny
41
+ } else if (nx !== null) {
42
+ return false // numeric < alphanumeric
43
+ } else if (ny !== null) {
44
+ return true
45
+ } else if (x !== y) {
46
+ return x > y
47
+ }
48
+ }
49
+ return false
50
+ }
51
+
52
+ /**
53
+ * Whether the copy in /Applications is a different app than the one this package carries.
54
+ *
55
+ * `npm i -g centralu` replaces the package and nothing else. That is deliberate — this
56
+ * project does not write into someone's /Applications unless asked (`centralu install`) —
57
+ * but the cost is that the two drift apart in silence, and the copy is the one people
58
+ * actually click. So the launcher says so. It still does not act.
59
+ *
60
+ * Not `isNewer`. Drift in either direction is worth a line: a copy *newer* than the package
61
+ * means an update went backwards, which is more surprising, not less.
62
+ *
63
+ * Lives beside `isNewer` because it ships and is tested for the same reason — a launcher
64
+ * already on someone's machine cannot be corrected later. 0.1.0-beta.1 is still out there
65
+ * answering "Already up to date" and always will be.
66
+ */
67
+ export function copyDiffers(pkgVersion, copyVersion) {
68
+ return typeof copyVersion === 'string' && copyVersion !== '' && copyVersion !== pkgVersion
69
+ }
70
+
71
+ function split(v) {
72
+ const dash = v.indexOf('-')
73
+ const core = (dash === -1 ? v : v.slice(0, dash)).split('.').map(Number)
74
+ const pre = dash === -1 ? null : v.slice(dash + 1)
75
+ return [core, pre]
76
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "centralu",
3
- "version": "0.1.0-beta.1",
4
- "description": "여러 에이전트 코딩 도구(Claude Code, Codex CLI)를 한 창에서 실행·관찰·제어하는 macOS 데스크톱 앱",
3
+ "version": "0.1.0-beta.10",
4
+ "description": "Run, watch and steer several Claude Code and Codex CLI sessions from one window",
5
5
  "license": "MIT",
6
6
  "author": "ijun17",
7
7
  "homepage": "https://github.com/ijun17/centralu",
@@ -17,6 +17,8 @@
17
17
  "codex",
18
18
  "agent",
19
19
  "macos",
20
+ "linux",
21
+ "windows",
20
22
  "desktop"
21
23
  ],
22
24
  "type": "module",
@@ -30,9 +32,14 @@
30
32
  "node": ">=22"
31
33
  },
32
34
  "os": [
33
- "darwin"
35
+ "darwin",
36
+ "linux",
37
+ "win32"
34
38
  ],
35
39
  "optionalDependencies": {
36
- "centralu-darwin-arm64": "0.1.0-beta.1"
40
+ "@centralu/darwin-arm64": "0.1.0-beta.10",
41
+ "@centralu/linux-arm64": "0.1.0-beta.10",
42
+ "@centralu/linux-x64": "0.1.0-beta.10",
43
+ "@centralu/win32-x64": "0.1.0-beta.10"
37
44
  }
38
45
  }