centralu 0.1.0-beta.1

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 +206 -0
  2. package/package.json +38 -0
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Centralu 실행기.
4
+ *
5
+ * **왜 npm으로 주는가:** macOS는 앱을 검사해서 위험하다고 판정하는 게 아니라,
6
+ * 파일에 붙은 `com.apple.quarantine` 딱지를 보고 개발자 신원을 따진다. 그 딱지는
7
+ * **받아온 프로그램이** 붙인다 — 브라우저는 붙이고 npm은 안 붙인다. 그래서 npm으로
8
+ * 설치하면 서명이 애드혹이어도 경고 없이 그냥 열린다 (docs/plans/beta-release-checklist.md §2).
9
+ *
10
+ * 앱 본체는 아키텍처별 패키지(`centralu-darwin-arm64`)에 들어 있다. 이 패키지는
11
+ * 그것을 찾아 띄우는 얇은 껍데기다 — esbuild·swc가 쓰는 것과 같은 구조.
12
+ */
13
+ import { execFileSync, spawn } from 'node:child_process'
14
+ import { existsSync, rmSync } from 'node:fs'
15
+ import { createRequire } from 'node:module'
16
+ import { dirname, join } from 'node:path'
17
+
18
+ const require = createRequire(import.meta.url)
19
+ const pkg = require('../package.json')
20
+
21
+ const APP_NAME = 'Centralu'
22
+ const BUNDLE = `${APP_NAME}.app`
23
+ const INSTALLED = `/Applications/${BUNDLE}`
24
+
25
+ /** 아키텍처별 패키지 안의 `.app`. 못 찾으면 null */
26
+ 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
+ }
33
+ }
34
+ return null
35
+ }
36
+
37
+ /**
38
+ * 지금 이 맥에서 왜 못 쓰는지를 **구체적으로** 말한다.
39
+ * "설치가 안 됐습니다"만 뜨면 사용자가 할 수 있는 일이 없다.
40
+ */
41
+ function explainMissing() {
42
+ if (process.platform !== 'darwin') {
43
+ return `${APP_NAME}는 macOS 앱입니다 (지금 플랫폼: ${process.platform}).`
44
+ }
45
+ if (process.arch !== 'arm64') {
46
+ return (
47
+ `${APP_NAME}는 아직 Apple Silicon 전용입니다 (지금 아키텍처: ${process.arch}).\n` +
48
+ 'Intel 맥 지원은 네이티브 애드온까지 함께 묶어야 해서 별도 작업으로 잡혀 있습니다.'
49
+ )
50
+ }
51
+ return (
52
+ '앱 본체 패키지(centralu-darwin-arm64)를 찾지 못했습니다.\n' +
53
+ '설치가 중간에 끊겼을 수 있습니다 — `npm i -g centralu`로 다시 설치해 주세요.'
54
+ )
55
+ }
56
+
57
+ function requireApp() {
58
+ const app = bundledApp()
59
+ if (!app || !existsSync(app)) {
60
+ console.error(explainMissing())
61
+ process.exit(1)
62
+ }
63
+ return app
64
+ }
65
+
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' })
71
+ r.on('exit', (code) => process.exit(code ?? 0))
72
+ }
73
+
74
+ /**
75
+ * `/Applications`에 복사한다.
76
+ *
77
+ * **postinstall로 몰래 하지 않는다.** 남의 `/Applications`에 조용히 쓰는 것은 신뢰를 깎고,
78
+ * pnpm은 postinstall을 기본 차단한다. 사용자가 원할 때 명시적으로 부르게 한다.
79
+ */
80
+ function install() {
81
+ const app = requireApp()
82
+ if (existsSync(INSTALLED)) {
83
+ console.log(`기존 ${INSTALLED}를 새 버전으로 교체합니다.`)
84
+ rmSync(INSTALLED, { recursive: true, force: true })
85
+ }
86
+ // cp가 아니라 ditto — 번들의 권한·확장속성을 그대로 옮긴다 (서명이 깨지지 않게)
87
+ execFileSync('/usr/bin/ditto', [app, INSTALLED], { stdio: 'inherit' })
88
+ console.log(`설치했습니다: ${INSTALLED}`)
89
+ console.log('이제 Launchpad·Spotlight에서도 찾을 수 있습니다.')
90
+ }
91
+
92
+ function uninstall() {
93
+ if (!existsSync(INSTALLED)) {
94
+ console.log(`${INSTALLED}가 없습니다 — 지울 것이 없습니다.`)
95
+ return
96
+ }
97
+ rmSync(INSTALLED, { recursive: true, force: true })
98
+ console.log(`지웠습니다: ${INSTALLED}`)
99
+ console.log('패키지 자체를 지우려면: npm uninstall -g centralu')
100
+ console.log(`대화 기록은 그대로 남아 있습니다 (~/.control-center). 지우려면 직접 지우세요.`)
101
+ }
102
+
103
+ /**
104
+ * npm 레지스트리가 곧 업데이트 채널이다 — 서버도 서명 키도 따로 필요 없다.
105
+ *
106
+ * 실패를 뭉뚱그리지 않는다. 못 닿은 것(네트워크)과 없는 것(404)은 **사용자가 할 일이 다르다** —
107
+ * 앞은 연결을 보라는 말이고, 뒤는 봐도 소용없다는 말이다.
108
+ */
109
+ async function latestVersion(timeoutMs = 2000) {
110
+ try {
111
+ const res = await fetch(`https://registry.npmjs.org/${pkg.name}/latest`, {
112
+ signal: AbortSignal.timeout(timeoutMs),
113
+ })
114
+ if (res.status === 404) return { ok: false, reason: 'missing' }
115
+ if (!res.ok) return { ok: false, reason: `http ${res.status}` }
116
+ const body = await res.json()
117
+ const version = body?.version
118
+ return typeof version === 'string' ? { ok: true, version } : { ok: false, reason: 'bad-response' }
119
+ } catch {
120
+ return { ok: false, reason: 'network' } // 네트워크가 없어도 앱은 떠야 한다
121
+ }
122
+ }
123
+
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
+
136
+ async function update() {
137
+ const res = await latestVersion(8000)
138
+ if (!res.ok) {
139
+ if (res.reason === 'missing') {
140
+ console.error(`레지스트리에 ${pkg.name}가 없습니다. 이름이 바뀌었거나 아직 발행 전입니다.`)
141
+ console.error('https://github.com/ijun17/centralu/releases 를 확인해 주세요.')
142
+ } else if (res.reason === 'network') {
143
+ console.error('레지스트리에 닿지 못했습니다. 네트워크를 확인하고 다시 시도해 주세요.')
144
+ } else {
145
+ console.error(`레지스트리가 예상 밖의 응답을 했습니다 (${res.reason}).`)
146
+ }
147
+ process.exit(1)
148
+ }
149
+ const latest = res.version
150
+ if (!isNewer(latest, pkg.version)) {
151
+ console.log(`이미 최신입니다 (${pkg.version}).`)
152
+ return
153
+ }
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' })
160
+ }
161
+ console.log('끝났습니다. 앱이 떠 있다면 다시 시작해 주세요.')
162
+ }
163
+
164
+ /** 앱을 띄운 뒤에만 알린다 — 업데이트 확인 때문에 실행이 늦어지면 안 된다 */
165
+ async function notifyIfOutdated() {
166
+ // 여기서는 실패를 **전부 삼킨다.** 앱을 띄우러 온 사람에게 레지스트리 사정을 말할 이유가 없다
167
+ const res = await latestVersion()
168
+ if (res.ok && isNewer(res.version, pkg.version)) {
169
+ console.log(`\n새 버전이 있습니다: ${pkg.version} → ${res.version}\n centralu update`)
170
+ }
171
+ }
172
+
173
+ const HELP = `${APP_NAME} ${pkg.version}
174
+
175
+ centralu 앱을 실행합니다
176
+ centralu install /Applications에 복사합니다 (Launchpad·Spotlight에 뜨게)
177
+ centralu uninstall /Applications에서 지웁니다 (대화 기록은 남습니다)
178
+ centralu update 새 버전이 있으면 올립니다
179
+ centralu --version 버전을 출력합니다
180
+
181
+ 필요한 것: Node 22+, 그리고 claude 또는 codex CLI (앱 첫 화면이 상태를 알려줍니다)`
182
+
183
+ const [cmd, ...rest] = process.argv.slice(2)
184
+ switch (cmd) {
185
+ case 'install':
186
+ install()
187
+ break
188
+ case 'uninstall':
189
+ uninstall()
190
+ break
191
+ case 'update':
192
+ await update()
193
+ break
194
+ case '--version':
195
+ case '-v':
196
+ console.log(pkg.version)
197
+ break
198
+ case '--help':
199
+ case '-h':
200
+ case 'help':
201
+ console.log(HELP)
202
+ break
203
+ default:
204
+ run(cmd ? [cmd, ...rest] : [])
205
+ await notifyIfOutdated()
206
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "centralu",
3
+ "version": "0.1.0-beta.1",
4
+ "description": "여러 에이전트 코딩 도구(Claude Code, Codex CLI)를 한 창에서 실행·관찰·제어하는 macOS 데스크톱 앱",
5
+ "license": "MIT",
6
+ "author": "ijun17",
7
+ "homepage": "https://github.com/ijun17/centralu",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ijun17/centralu.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/ijun17/centralu/issues"
14
+ },
15
+ "keywords": [
16
+ "claude-code",
17
+ "codex",
18
+ "agent",
19
+ "macos",
20
+ "desktop"
21
+ ],
22
+ "type": "module",
23
+ "bin": {
24
+ "centralu": "bin/centralu.mjs"
25
+ },
26
+ "files": [
27
+ "bin"
28
+ ],
29
+ "engines": {
30
+ "node": ">=22"
31
+ },
32
+ "os": [
33
+ "darwin"
34
+ ],
35
+ "optionalDependencies": {
36
+ "centralu-darwin-arm64": "0.1.0-beta.1"
37
+ }
38
+ }