centralu 0.1.0-beta.1 → 0.1.0-beta.2

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