centralu 0.1.0-beta.1 → 0.1.0-beta.3

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
@@ -9,10 +9,17 @@
9
9
  *
10
10
  * 앱 본체는 아키텍처별 패키지(`centralu-darwin-arm64`)에 들어 있다. 이 패키지는
11
11
  * 그것을 찾아 띄우는 얇은 껍데기다 — esbuild·swc가 쓰는 것과 같은 구조.
12
+ *
13
+ * Linux is served from the same shim (issue #14). The two platforms disagree about what
14
+ * "the app" even is — macOS hands a `.app` directory to LaunchServices, Linux runs a
15
+ * self-contained AppImage itself — so that difference lives in one table (`TARGETS`)
16
+ * instead of being rediscovered in every function.
12
17
  */
13
18
  import { execFileSync, spawn } from 'node:child_process'
14
- import { existsSync, rmSync } from 'node:fs'
19
+ import { isNewer } from './semver.mjs'
20
+ import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
15
21
  import { createRequire } from 'node:module'
22
+ import { homedir } from 'node:os'
16
23
  import { dirname, join } from 'node:path'
17
24
 
18
25
  const require = createRequire(import.meta.url)
@@ -22,63 +29,121 @@ const APP_NAME = 'Centralu'
22
29
  const BUNDLE = `${APP_NAME}.app`
23
30
  const INSTALLED = `/Applications/${BUNDLE}`
24
31
 
25
- /** 아키텍처별 패키지 안의 `.app`. 못 찾으면 null */
32
+ /** Where a menu entry for the app goes on freedesktop desktops */
33
+ const DESKTOP_ENTRY = join(homedir(), '.local/share/applications/centralu.desktop')
34
+
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
+ }
52
+
53
+ const TARGET = TARGETS[`${process.platform}-${process.arch}`]
54
+
55
+ /** 아키텍처별 패키지 안의 앱. 못 찾으면 null */
26
56
  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
- }
57
+ if (!TARGET) return null
58
+ let root
59
+ try {
60
+ root = dirname(require.resolve(`${TARGET.pkg}/package.json`))
61
+ } catch {
62
+ return null // 이 아키텍처용 패키지가 안 깔린 것 — 아래에서 이유를 말한다
33
63
  }
34
- return null
64
+ const app = join(root, TARGET.artifact)
65
+ return existsSync(app) ? app : null
35
66
  }
36
67
 
37
68
  /**
38
- * 지금 이 맥에서 왜 못 쓰는지를 **구체적으로** 말한다.
69
+ * 지금 이 기계에서 왜 못 쓰는지를 **구체적으로** 말한다.
39
70
  * "설치가 안 됐습니다"만 뜨면 사용자가 할 수 있는 일이 없다.
40
71
  */
41
72
  function explainMissing() {
42
- if (process.platform !== 'darwin') {
43
- return `${APP_NAME}는 macOS 앱입니다 (지금 플랫폼: ${process.platform}).`
44
- }
45
- if (process.arch !== 'arm64') {
73
+ if (!TARGET) {
74
+ // Kept as its own case: "not published for your machine" and "not published for
75
+ // Intel Macs, and here is the reason" send the user to different places.
76
+ if (process.platform === 'darwin') {
77
+ return (
78
+ `${APP_NAME}는 아직 Apple Silicon 전용입니다 (지금 아키텍처: ${process.arch}).\n` +
79
+ 'Intel 맥 지원은 네이티브 애드온까지 함께 묶어야 해서 별도 작업으로 잡혀 있습니다.'
80
+ )
81
+ }
82
+ const supported = Object.keys(TARGETS).join(', ')
46
83
  return (
47
- `${APP_NAME}는 아직 Apple Silicon 전용입니다 (지금 아키텍처: ${process.arch}).\n` +
48
- 'Intel 맥 지원은 네이티브 애드온까지 함께 묶어야 해서 별도 작업으로 잡혀 있습니다.'
84
+ `${APP_NAME}는 아직 ${process.platform}/${process.arch}를 지원하지 않습니다.\n` +
85
+ `지금 지원하는 조합: ${supported}\n` +
86
+ 'https://github.com/ijun17/centralu/issues/14 에 진행 상황이 있습니다.'
49
87
  )
50
88
  }
51
89
  return (
52
- '앱 본체 패키지(centralu-darwin-arm64)를 찾지 못했습니다.\n' +
90
+ `앱 본체 패키지(${TARGET.pkg})를 찾지 못했습니다.\n` +
53
91
  '설치가 중간에 끊겼을 수 있습니다 — `npm i -g centralu`로 다시 설치해 주세요.'
54
92
  )
55
93
  }
56
94
 
57
95
  function requireApp() {
58
96
  const app = bundledApp()
59
- if (!app || !existsSync(app)) {
97
+ if (!app) {
60
98
  console.error(explainMissing())
61
99
  process.exit(1)
62
100
  }
63
101
  return app
64
102
  }
65
103
 
66
- /** 앱을 띄운다. `/Applications`에 설치돼 있으면 그쪽을 먼저 쓴다 */
104
+ /**
105
+ * 앱을 띄운다. `/Applications`에 설치돼 있으면 그쪽을 먼저 쓴다.
106
+ *
107
+ * That preference is macOS-only, and not for lack of an equivalent: `centralu install`
108
+ * on Linux writes a launcher that points back at this same package, so there is never a
109
+ * second copy to prefer.
110
+ */
67
111
  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' })
112
+ if (process.platform === 'darwin') {
113
+ const app = existsSync(INSTALLED) ? INSTALLED : requireApp()
114
+ // `open`은 LaunchServices를 거친다 — Dock 아이콘·단일 인스턴스가 그래야 제대로 산다
115
+ const r = spawn('open', ['-a', app, ...(args.length ? ['--args', ...args] : [])], { stdio: 'inherit' })
116
+ r.on('exit', (code) => process.exit(code ?? 0))
117
+ return
118
+ }
119
+ // Linux has no LaunchServices — the AppImage is its own launcher, so run it directly.
120
+ //
121
+ // Staying attached to the terminal is on purpose. The two usual first failures on
122
+ // Linux both explain themselves on stderr and nowhere else: a missing FUSE
123
+ // ("AppImages require FUSE to run", with the workaround in the same sentence) and a
124
+ // missing system library from the loader. Detaching would hand back the shell prompt
125
+ // and throw away the one line that says what to do.
126
+ const app = requireApp()
127
+ const r = spawn(app, args, { stdio: 'inherit' })
128
+ r.on('error', (e) => {
129
+ console.error(`${app}를 실행하지 못했습니다: ${e.message}`)
130
+ // EACCES here means the executable bit did not survive the trip through npm, which
131
+ // is invisible from the message alone.
132
+ if (e.code === 'EACCES') console.error(`실행 권한이 없습니다 — \`chmod +x "${app}"\` 뒤에 다시 시도해 주세요.`)
133
+ process.exit(1)
134
+ })
71
135
  r.on('exit', (code) => process.exit(code ?? 0))
72
136
  }
73
137
 
74
138
  /**
75
- * `/Applications`에 복사한다.
139
+ * 앱을 시스템 메뉴에 등록한다.
76
140
  *
77
141
  * **postinstall로 몰래 하지 않는다.** 남의 `/Applications`에 조용히 쓰는 것은 신뢰를 깎고,
78
142
  * pnpm은 postinstall을 기본 차단한다. 사용자가 원할 때 명시적으로 부르게 한다.
79
143
  */
80
144
  function install() {
81
145
  const app = requireApp()
146
+ if (process.platform !== 'darwin') return installDesktopEntry(app)
82
147
  if (existsSync(INSTALLED)) {
83
148
  console.log(`기존 ${INSTALLED}를 새 버전으로 교체합니다.`)
84
149
  rmSync(INSTALLED, { recursive: true, force: true })
@@ -89,15 +154,50 @@ function install() {
89
154
  console.log('이제 Launchpad·Spotlight에서도 찾을 수 있습니다.')
90
155
  }
91
156
 
157
+ /**
158
+ * The Linux answer to "copy it into /Applications so it shows up in Launchpad".
159
+ *
160
+ * It writes a launcher, not a copy. On macOS the `.app` has to move because
161
+ * LaunchServices only indexes a few directories; on Linux the menu indexes `.desktop`
162
+ * files that may point anywhere, so pointing at the npm package leaves exactly one copy
163
+ * of the binary — and `npm i -g centralu@newer` then updates the menu entry too,
164
+ * because the path it names does not change.
165
+ */
166
+ function installDesktopEntry(app) {
167
+ const icon = join(dirname(app), 'icon.png')
168
+ const entry = [
169
+ '[Desktop Entry]',
170
+ 'Type=Application',
171
+ `Name=${APP_NAME}`,
172
+ 'Comment=Run, watch and steer several coding agents in one window',
173
+ // Quoted because the path runs through node_modules and npm prefixes can contain spaces
174
+ `Exec="${app}" %U`,
175
+ `Icon=${existsSync(icon) ? icon : 'centralu'}`,
176
+ 'Terminal=false',
177
+ 'Categories=Development;Utility;',
178
+ // Guessed, not measured: this must equal the WM class the running window reports,
179
+ // and we have no Linux desktop here to read it off. If the taskbar shows two icons
180
+ // for one window, this line is why.
181
+ 'StartupWMClass=Centralu',
182
+ '',
183
+ ].join('\n')
184
+ mkdirSync(dirname(DESKTOP_ENTRY), { recursive: true })
185
+ writeFileSync(DESKTOP_ENTRY, entry)
186
+ chmodSync(DESKTOP_ENTRY, 0o755)
187
+ console.log(`등록했습니다: ${DESKTOP_ENTRY}`)
188
+ console.log('이제 앱 목록에서도 찾을 수 있습니다 (데스크톱에 따라 다시 로그인해야 보일 수 있습니다).')
189
+ }
190
+
92
191
  function uninstall() {
93
- if (!existsSync(INSTALLED)) {
94
- console.log(`${INSTALLED}가 없습니다 — 지울 것이 없습니다.`)
192
+ const installed = process.platform === 'darwin' ? INSTALLED : DESKTOP_ENTRY
193
+ if (!existsSync(installed)) {
194
+ console.log(`${installed}가 없습니다 — 지울 것이 없습니다.`)
95
195
  return
96
196
  }
97
- rmSync(INSTALLED, { recursive: true, force: true })
98
- console.log(`지웠습니다: ${INSTALLED}`)
197
+ rmSync(installed, { recursive: true, force: true })
198
+ console.log(`지웠습니다: ${installed}`)
99
199
  console.log('패키지 자체를 지우려면: npm uninstall -g centralu')
100
- console.log(`대화 기록은 그대로 남아 있습니다 (~/.control-center). 지우려면 직접 지우세요.`)
200
+ console.log(`대화 기록은 그대로 남아 있습니다 (~/.centralu). 지우려면 직접 지우세요.`)
101
201
  }
102
202
 
103
203
  /**
@@ -121,17 +221,6 @@ async function latestVersion(timeoutMs = 2000) {
121
221
  }
122
222
  }
123
223
 
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
224
 
136
225
  async function update() {
137
226
  const res = await latestVersion(8000)
@@ -153,9 +242,12 @@ async function update() {
153
242
  }
154
243
  console.log(`${pkg.version} → ${latest} 로 올립니다.`)
155
244
  execFileSync('npm', ['i', '-g', `${pkg.name}@${latest}`], { stdio: 'inherit' })
156
- // /Applications에 넣어둔 사람은 그쪽도 함께 갱신해야 옛 버전이 남지 않는다
157
- if (existsSync(INSTALLED)) {
158
- console.log(`${INSTALLED}도 갱신합니다.`)
245
+ // /Applications에 넣어둔 사람은 그쪽도 함께 갱신해야 옛 버전이 남지 않는다.
246
+ // On Linux the menu entry points at the package instead of a copy, so rewriting it is
247
+ // cheap — but it is still worth doing, because the Exec path is what would go stale.
248
+ const installed = process.platform === 'darwin' ? INSTALLED : DESKTOP_ENTRY
249
+ if (existsSync(installed)) {
250
+ console.log(`${installed}도 갱신합니다.`)
159
251
  execFileSync(process.argv[1], ['install'], { stdio: 'inherit' })
160
252
  }
161
253
  console.log('끝났습니다. 앱이 떠 있다면 다시 시작해 주세요.')
@@ -173,8 +265,8 @@ async function notifyIfOutdated() {
173
265
  const HELP = `${APP_NAME} ${pkg.version}
174
266
 
175
267
  centralu 앱을 실행합니다
176
- centralu install /Applications에 복사합니다 (Launchpad·Spotlight에 뜨게)
177
- centralu uninstall /Applications에서 지웁니다 (대화 기록은 남습니다)
268
+ centralu install 앱 목록에 등록합니다 (macOS는 /Applications, Linux는 메뉴 항목)
269
+ centralu uninstall 그 등록을 지웁니다 (대화 기록은 남습니다)
178
270
  centralu update 새 버전이 있으면 올립니다
179
271
  centralu --version 버전을 출력합니다
180
272
 
package/bin/semver.mjs ADDED
@@ -0,0 +1,57 @@
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 "이미 최신입니다" while beta.2 sat
14
+ * 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
+ function split(v) {
53
+ const dash = v.indexOf('-')
54
+ const core = (dash === -1 ? v : v.slice(0, dash)).split('.').map(Number)
55
+ const pre = dash === -1 ? null : v.slice(dash + 1)
56
+ return [core, pre]
57
+ }
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.3",
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,7 @@
17
17
  "codex",
18
18
  "agent",
19
19
  "macos",
20
+ "linux",
20
21
  "desktop"
21
22
  ],
22
23
  "type": "module",
@@ -30,9 +31,12 @@
30
31
  "node": ">=22"
31
32
  },
32
33
  "os": [
33
- "darwin"
34
+ "darwin",
35
+ "linux"
34
36
  ],
35
37
  "optionalDependencies": {
36
- "centralu-darwin-arm64": "0.1.0-beta.1"
38
+ "centralu-darwin-arm64": "0.1.0-beta.3",
39
+ "centralu-linux-arm64": "0.1.0-beta.3",
40
+ "centralu-linux-x64": "0.1.0-beta.3"
37
41
  }
38
42
  }