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 +136 -44
- package/bin/semver.mjs +57 -0
- package/package.json +8 -4
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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 (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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}는 아직
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
97
|
+
if (!app) {
|
|
60
98
|
console.error(explainMissing())
|
|
61
99
|
process.exit(1)
|
|
62
100
|
}
|
|
63
101
|
return app
|
|
64
102
|
}
|
|
65
103
|
|
|
66
|
-
/**
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
94
|
-
|
|
192
|
+
const installed = process.platform === 'darwin' ? INSTALLED : DESKTOP_ENTRY
|
|
193
|
+
if (!existsSync(installed)) {
|
|
194
|
+
console.log(`${installed}가 없습니다 — 지울 것이 없습니다.`)
|
|
95
195
|
return
|
|
96
196
|
}
|
|
97
|
-
rmSync(
|
|
98
|
-
console.log(`지웠습니다: ${
|
|
197
|
+
rmSync(installed, { recursive: true, force: true })
|
|
198
|
+
console.log(`지웠습니다: ${installed}`)
|
|
99
199
|
console.log('패키지 자체를 지우려면: npm uninstall -g centralu')
|
|
100
|
-
console.log(`대화 기록은 그대로 남아 있습니다 (~/.
|
|
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
|
-
|
|
158
|
-
|
|
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
|
|
177
|
-
centralu uninstall
|
|
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.
|
|
4
|
-
"description": "
|
|
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.
|
|
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
|
}
|