@suisya-systems/renga 0.18.1 → 0.18.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/README.md +53 -49
- package/bin/cli.js +25 -25
- package/package.json +39 -39
- package/scripts/install.js +275 -275
package/README.md
CHANGED
|
@@ -1,49 +1,53 @@
|
|
|
1
|
-
# @suisya-systems/renga
|
|
2
|
-
|
|
3
|
-
Claude Code
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
## Install
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
npm install -g @suisya-systems/renga
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Migrating from previous `
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
npm uninstall -g
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
Migrating from the upstream `
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
npm uninstall -g
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
## Usage
|
|
26
|
-
|
|
27
|
-
```bash
|
|
28
|
-
renga # Launch in current directory
|
|
29
|
-
renga /path/to/project # Launch in specified directory
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## Features
|
|
33
|
-
|
|
34
|
-
- Multi-pane terminal splits (vertical/horizontal)
|
|
35
|
-
- File tree sidebar with syntax-highlighted preview
|
|
36
|
-
- Tab workspaces
|
|
37
|
-
- Claude Code auto-detection (pane border turns orange)
|
|
38
|
-
- Mouse support (click, drag resize, text selection)
|
|
39
|
-
- Terminal scrollback (10,000 lines)
|
|
40
|
-
- Cross-platform (Windows, macOS, Linux)
|
|
41
|
-
|
|
42
|
-
## Links
|
|
43
|
-
|
|
44
|
-
- [GitHub
|
|
45
|
-
- [
|
|
46
|
-
|
|
47
|
-
##
|
|
48
|
-
|
|
49
|
-
|
|
1
|
+
# @suisya-systems/renga
|
|
2
|
+
|
|
3
|
+
A terminal multiplexer purpose-built for running multiple [Claude Code](https://docs.anthropic.com/en/docs/claude-code) sessions side-by-side — Claude-aware pane detection, peer messaging between Claude panes via a built-in MCP channel, and an IME-aware composition overlay for JP/CJK input.
|
|
4
|
+
|
|
5
|
+
For people running 2+ Claude Code instances in parallel (orchestrator + workers, side-by-side comparisons, etc.). If you only ever run one Claude at a time, the value over a plain terminal is small.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @suisya-systems/renga
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Migrating from previous `ccmux-fork`:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm uninstall -g ccmux-fork && npm install -g @suisya-systems/renga
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Migrating from the upstream `ccmux-cli`:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm uninstall -g ccmux-cli && npm install -g @suisya-systems/renga
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
renga # Launch in current directory
|
|
29
|
+
renga /path/to/project # Launch in specified directory
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- Multi-pane terminal splits (vertical/horizontal)
|
|
35
|
+
- File tree sidebar with syntax-highlighted preview
|
|
36
|
+
- Tab workspaces
|
|
37
|
+
- Claude Code auto-detection (pane border turns orange)
|
|
38
|
+
- Mouse support (click, drag resize, text selection)
|
|
39
|
+
- Terminal scrollback (10,000 lines)
|
|
40
|
+
- Cross-platform (Windows, macOS, Linux)
|
|
41
|
+
|
|
42
|
+
## Links
|
|
43
|
+
|
|
44
|
+
- [GitHub](https://github.com/suisya-systems/renga)
|
|
45
|
+
- [Full README (with peer messaging, IME overlay, keybindings, configuration)](https://github.com/suisya-systems/renga#readme)
|
|
46
|
+
|
|
47
|
+
## History
|
|
48
|
+
|
|
49
|
+
renga was originally derived from [Shin-sibainu/ccmux](https://github.com/Shin-sibainu/ccmux) and has since evolved independently — peer messaging, IME overlay, layout TOML, and the bilingual UX layer are renga-specific. See [`BRANCHING.md`](https://github.com/suisya-systems/renga/blob/main/BRANCHING.md) for the divergence policy.
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
MIT — upstream `Shin-sibainu/ccmux` copyright is retained per the license terms.
|
package/bin/cli.js
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
const { execFileSync } = require('child_process');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const fs = require('fs');
|
|
6
|
-
|
|
7
|
-
const isWindows = process.platform === 'win32';
|
|
8
|
-
const binaryName = isWindows ? 'renga.exe' : 'renga';
|
|
9
|
-
const binaryPath = path.join(__dirname, binaryName);
|
|
10
|
-
|
|
11
|
-
if (!fs.existsSync(binaryPath)) {
|
|
12
|
-
console.error('renga binary not found. Try reinstalling: npm install -g @suisya-systems/renga');
|
|
13
|
-
process.exit(1);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
try {
|
|
17
|
-
execFileSync(binaryPath, process.argv.slice(2), {
|
|
18
|
-
stdio: 'inherit',
|
|
19
|
-
env: process.env,
|
|
20
|
-
});
|
|
21
|
-
} catch (err) {
|
|
22
|
-
if (err.status !== null) {
|
|
23
|
-
process.exit(err.status);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
const isWindows = process.platform === 'win32';
|
|
8
|
+
const binaryName = isWindows ? 'renga.exe' : 'renga';
|
|
9
|
+
const binaryPath = path.join(__dirname, binaryName);
|
|
10
|
+
|
|
11
|
+
if (!fs.existsSync(binaryPath)) {
|
|
12
|
+
console.error('renga binary not found. Try reinstalling: npm install -g @suisya-systems/renga');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
execFileSync(binaryPath, process.argv.slice(2), {
|
|
18
|
+
stdio: 'inherit',
|
|
19
|
+
env: process.env,
|
|
20
|
+
});
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (err.status !== null) {
|
|
23
|
+
process.exit(err.status);
|
|
24
|
+
}
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@suisya-systems/renga",
|
|
3
|
-
"version": "0.18.
|
|
4
|
-
"description": "Claude Code Multiplexer (fork, formerly ccmux-fork) — manage multiple Claude Code instances in TUI split panes",
|
|
5
|
-
"bin": {
|
|
6
|
-
"renga": "bin/cli.js"
|
|
7
|
-
},
|
|
8
|
-
"scripts": {
|
|
9
|
-
"postinstall": "node scripts/install.js"
|
|
10
|
-
},
|
|
11
|
-
"keywords": [
|
|
12
|
-
"claude",
|
|
13
|
-
"claude-code",
|
|
14
|
-
"terminal",
|
|
15
|
-
"multiplexer",
|
|
16
|
-
"tui",
|
|
17
|
-
"pane",
|
|
18
|
-
"split",
|
|
19
|
-
"tmux"
|
|
20
|
-
],
|
|
21
|
-
"author": "happy-ryo",
|
|
22
|
-
"contributors": [
|
|
23
|
-
"Shin-sibainu (original author of ccmux)"
|
|
24
|
-
],
|
|
25
|
-
"license": "MIT",
|
|
26
|
-
"repository": {
|
|
27
|
-
"type": "git",
|
|
28
|
-
"url": "https://github.com/
|
|
29
|
-
},
|
|
30
|
-
"homepage": "https://github.com/
|
|
31
|
-
"os": [
|
|
32
|
-
"win32",
|
|
33
|
-
"darwin",
|
|
34
|
-
"linux"
|
|
35
|
-
],
|
|
36
|
-
"engines": {
|
|
37
|
-
"node": ">=16"
|
|
38
|
-
}
|
|
39
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@suisya-systems/renga",
|
|
3
|
+
"version": "0.18.3",
|
|
4
|
+
"description": "Claude Code Multiplexer (fork, formerly ccmux-fork) — manage multiple Claude Code instances in TUI split panes",
|
|
5
|
+
"bin": {
|
|
6
|
+
"renga": "bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"postinstall": "node scripts/install.js"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"claude",
|
|
13
|
+
"claude-code",
|
|
14
|
+
"terminal",
|
|
15
|
+
"multiplexer",
|
|
16
|
+
"tui",
|
|
17
|
+
"pane",
|
|
18
|
+
"split",
|
|
19
|
+
"tmux"
|
|
20
|
+
],
|
|
21
|
+
"author": "happy-ryo",
|
|
22
|
+
"contributors": [
|
|
23
|
+
"Shin-sibainu (original author of ccmux)"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/suisya-systems/renga"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/suisya-systems/renga",
|
|
31
|
+
"os": [
|
|
32
|
+
"win32",
|
|
33
|
+
"darwin",
|
|
34
|
+
"linux"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=16"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/scripts/install.js
CHANGED
|
@@ -1,275 +1,275 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const https = require('https');
|
|
4
|
-
const crypto = require('crypto');
|
|
5
|
-
|
|
6
|
-
const VERSION = require('../package.json').version;
|
|
7
|
-
const REPO = '
|
|
8
|
-
const MAX_REDIRECTS = 5;
|
|
9
|
-
const ALLOWED_REDIRECT_HOSTS = new Set([
|
|
10
|
-
'github.com',
|
|
11
|
-
'objects.githubusercontent.com',
|
|
12
|
-
'release-assets.githubusercontent.com',
|
|
13
|
-
'github-releases.githubusercontent.com',
|
|
14
|
-
]);
|
|
15
|
-
|
|
16
|
-
function getPlatformBinary() {
|
|
17
|
-
const platform = process.platform;
|
|
18
|
-
const arch = process.arch;
|
|
19
|
-
|
|
20
|
-
if (platform === 'win32' && arch === 'x64') return 'renga-windows-x64.exe';
|
|
21
|
-
if (platform === 'darwin' && arch === 'arm64') return 'renga-macos-arm64';
|
|
22
|
-
if (platform === 'darwin' && arch === 'x64') return 'renga-macos-x64';
|
|
23
|
-
if (platform === 'linux' && arch === 'x64') return 'renga-linux-x64';
|
|
24
|
-
|
|
25
|
-
console.error(`Unsupported platform: ${platform}-${arch}`);
|
|
26
|
-
process.exit(1);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function resolveAndValidateUrl(rawUrl, baseUrl) {
|
|
30
|
-
const parsed = new URL(rawUrl, baseUrl);
|
|
31
|
-
if (parsed.protocol !== 'https:') {
|
|
32
|
-
throw new Error(`Refusing non-HTTPS URL: ${parsed.toString()}`);
|
|
33
|
-
}
|
|
34
|
-
if (!ALLOWED_REDIRECT_HOSTS.has(parsed.hostname)) {
|
|
35
|
-
throw new Error(`Refusing download from unexpected host: ${parsed.hostname}`);
|
|
36
|
-
}
|
|
37
|
-
return parsed;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function download(url, dest, redirects = 0, baseUrl) {
|
|
41
|
-
return new Promise((resolve, reject) => {
|
|
42
|
-
if (redirects > MAX_REDIRECTS) {
|
|
43
|
-
reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`));
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
let requestUrl;
|
|
47
|
-
try {
|
|
48
|
-
requestUrl = resolveAndValidateUrl(url, baseUrl);
|
|
49
|
-
} catch (err) {
|
|
50
|
-
reject(err);
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
https.get(requestUrl, { headers: { 'User-Agent': 'renga-installer' } }, (res) => {
|
|
54
|
-
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
55
|
-
download(res.headers.location, dest, redirects + 1, requestUrl).then(resolve, reject);
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
if (res.statusCode !== 200) {
|
|
59
|
-
reject(new Error(`Download failed: HTTP ${res.statusCode}`));
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
const file = fs.createWriteStream(dest);
|
|
63
|
-
file.on('error', reject);
|
|
64
|
-
res.pipe(file);
|
|
65
|
-
file.on('finish', () => {
|
|
66
|
-
file.close();
|
|
67
|
-
resolve();
|
|
68
|
-
});
|
|
69
|
-
}).on('error', reject);
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function fetchText(url, redirects = 0, baseUrl) {
|
|
74
|
-
return new Promise((resolve, reject) => {
|
|
75
|
-
if (redirects > MAX_REDIRECTS) {
|
|
76
|
-
reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`));
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
let requestUrl;
|
|
80
|
-
try {
|
|
81
|
-
requestUrl = resolveAndValidateUrl(url, baseUrl);
|
|
82
|
-
} catch (err) {
|
|
83
|
-
reject(err);
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
https.get(requestUrl, { headers: { 'User-Agent': 'renga-installer' } }, (res) => {
|
|
87
|
-
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
88
|
-
fetchText(res.headers.location, redirects + 1, requestUrl).then(resolve, reject);
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
if (res.statusCode !== 200) {
|
|
92
|
-
reject(new Error(`Fetch failed: HTTP ${res.statusCode}`));
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
let data = '';
|
|
96
|
-
res.on('data', (chunk) => { data += chunk; });
|
|
97
|
-
res.on('end', () => resolve(data));
|
|
98
|
-
}).on('error', reject);
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function sha256(filePath) {
|
|
103
|
-
return new Promise((resolve, reject) => {
|
|
104
|
-
const hash = crypto.createHash('sha256');
|
|
105
|
-
const stream = fs.createReadStream(filePath);
|
|
106
|
-
stream.on('data', (chunk) => hash.update(chunk));
|
|
107
|
-
stream.on('end', () => resolve(hash.digest('hex')));
|
|
108
|
-
stream.on('error', reject);
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function getExpectedChecksum(checksums, binaryName) {
|
|
113
|
-
for (const line of checksums.split(/\r?\n/)) {
|
|
114
|
-
const trimmed = line.trim();
|
|
115
|
-
if (!trimmed) continue;
|
|
116
|
-
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
|
|
117
|
-
if (!match) continue;
|
|
118
|
-
if (match[2] === binaryName) {
|
|
119
|
-
return match[1].toLowerCase();
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
throw new Error(`Checksum entry not found for ${binaryName}`);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function cleanupFile(filePath) {
|
|
126
|
-
try {
|
|
127
|
-
fs.rmSync(filePath, { force: true });
|
|
128
|
-
} catch (_) {
|
|
129
|
-
// Best-effort cleanup only.
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Replace `dest` with `tempDest`.
|
|
134
|
-
//
|
|
135
|
-
// Not truly atomic: on Windows a POSIX-style rename isn't available,
|
|
136
|
-
// and even on Unix we run two renames back-to-back (dest -> backup,
|
|
137
|
-
// then tempDest -> dest), so there is a brief window where `dest`
|
|
138
|
-
// does not exist. If the process is killed inside that window the
|
|
139
|
-
// user is left with no binary at the canonical path until they
|
|
140
|
-
// rerun the installer.
|
|
141
|
-
//
|
|
142
|
-
// The sequence is still a strong improvement over the previous
|
|
143
|
-
// "rm old, rename new" path because:
|
|
144
|
-
//
|
|
145
|
-
// - The old binary is preserved at a unique backup path for as long
|
|
146
|
-
// as the install could fail. A rename-level failure restores it.
|
|
147
|
-
// - The backup path is per-install (suffix with process PID and a
|
|
148
|
-
// timestamp) so a stale `.bak` from a crashed previous run isn't
|
|
149
|
-
// clobbered, and a failed install leaves behind a file the user
|
|
150
|
-
// can inspect or restore manually.
|
|
151
|
-
// - The rename can fail on Windows when an antivirus scanner or
|
|
152
|
-
// another process briefly holds the old binary open (EBUSY /
|
|
153
|
-
// EPERM / EACCES). A small bounded retry with exponential
|
|
154
|
-
// backoff (50 / 100 / 200 / 400 ms between attempts, five
|
|
155
|
-
// attempts total, ~750 ms total wait before giving up) absorbs
|
|
156
|
-
// most of that transient lock contention.
|
|
157
|
-
function replaceBinaryWithBackup(tempDest, dest) {
|
|
158
|
-
const backup = `${dest}.${process.pid}.${Date.now()}.bak`;
|
|
159
|
-
const hadExisting = fs.existsSync(dest);
|
|
160
|
-
|
|
161
|
-
if (hadExisting) {
|
|
162
|
-
fs.renameSync(dest, backup);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const maxAttempts = 5;
|
|
166
|
-
let lastErr = null;
|
|
167
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
168
|
-
try {
|
|
169
|
-
fs.renameSync(tempDest, dest);
|
|
170
|
-
lastErr = null;
|
|
171
|
-
break;
|
|
172
|
-
} catch (err) {
|
|
173
|
-
lastErr = err;
|
|
174
|
-
const retryable =
|
|
175
|
-
err && (err.code === 'EBUSY' || err.code === 'EPERM' || err.code === 'EACCES');
|
|
176
|
-
if (!retryable || attempt === maxAttempts) break;
|
|
177
|
-
const waitMs = 50 * Math.pow(2, attempt - 1);
|
|
178
|
-
const until = Date.now() + waitMs;
|
|
179
|
-
while (Date.now() < until) {
|
|
180
|
-
// Busy-wait is fine here; install.js is short-lived and
|
|
181
|
-
// single-threaded, and we want to stay synchronous.
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (lastErr) {
|
|
187
|
-
if (hadExisting) {
|
|
188
|
-
try {
|
|
189
|
-
fs.renameSync(backup, dest);
|
|
190
|
-
} catch (restoreErr) {
|
|
191
|
-
lastErr = new Error(
|
|
192
|
-
`Failed to install new binary and failed to restore old binary.\n` +
|
|
193
|
-
` Install error: ${lastErr.message}\n` +
|
|
194
|
-
` Restore error: ${restoreErr.message}\n` +
|
|
195
|
-
` Backup lives at: ${backup}`
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
throw lastErr;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (hadExisting) {
|
|
203
|
-
cleanupFile(backup);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
async function main() {
|
|
208
|
-
const binaryName = getPlatformBinary();
|
|
209
|
-
const baseUrl = `https://github.com/${REPO}/releases/download/v${VERSION}`;
|
|
210
|
-
const url = `${baseUrl}/${binaryName}`;
|
|
211
|
-
const binDir = path.join(__dirname, '..', 'bin');
|
|
212
|
-
const isWindows = process.platform === 'win32';
|
|
213
|
-
const dest = path.join(binDir, isWindows ? 'renga.exe' : 'renga');
|
|
214
|
-
const tempDest = `${dest}.tmp`;
|
|
215
|
-
|
|
216
|
-
console.log(`Downloading renga v${VERSION} for ${process.platform}-${process.arch}...`);
|
|
217
|
-
|
|
218
|
-
try {
|
|
219
|
-
fs.mkdirSync(binDir, { recursive: true });
|
|
220
|
-
cleanupFile(tempDest);
|
|
221
|
-
|
|
222
|
-
await download(url, tempDest);
|
|
223
|
-
|
|
224
|
-
const checksums = await fetchText(`${baseUrl}/checksums.txt`);
|
|
225
|
-
const expectedHash = getExpectedChecksum(checksums, binaryName);
|
|
226
|
-
const actualHash = await sha256(tempDest);
|
|
227
|
-
if (actualHash !== expectedHash) {
|
|
228
|
-
throw new Error(
|
|
229
|
-
[
|
|
230
|
-
'Checksum verification FAILED - downloaded binary does not match.',
|
|
231
|
-
` Expected: ${expectedHash}`,
|
|
232
|
-
` Actual: ${actualHash}`,
|
|
233
|
-
].join('\n')
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
console.log('Checksum verified.');
|
|
237
|
-
|
|
238
|
-
if (!isWindows) {
|
|
239
|
-
fs.chmodSync(tempDest, 0o755);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
replaceBinaryWithBackup(tempDest, dest);
|
|
243
|
-
|
|
244
|
-
const BLUE = '\x1b[38;2;88;166;255m';
|
|
245
|
-
const DIM = '\x1b[38;2;110;118;129m';
|
|
246
|
-
const RESET = '\x1b[0m';
|
|
247
|
-
console.log('');
|
|
248
|
-
console.log(`${BLUE}██████╗ ███████╗███╗ ██╗ ██████╗ █████╗ ${RESET}`);
|
|
249
|
-
console.log(`${BLUE}██╔══██╗██╔════╝████╗ ██║██╔════╝ ██╔══██╗${RESET}`);
|
|
250
|
-
console.log(`${BLUE}██████╔╝█████╗ ██╔██╗ ██║██║ ███╗███████║${RESET}`);
|
|
251
|
-
console.log(`${BLUE}██╔══██╗██╔══╝ ██║╚██╗██║██║ ██║██╔══██║${RESET}`);
|
|
252
|
-
console.log(`${BLUE}██║ ██║███████╗██║ ╚████║╚██████╔╝██║ ██║${RESET}`);
|
|
253
|
-
console.log(`${BLUE}╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝${RESET}`);
|
|
254
|
-
console.log('');
|
|
255
|
-
console.log(`${DIM} Claude Code Multiplexer v${VERSION}${RESET}`);
|
|
256
|
-
console.log(`${DIM} Run 'renga' to start.${RESET}`);
|
|
257
|
-
console.log('');
|
|
258
|
-
} catch (err) {
|
|
259
|
-
cleanupFile(tempDest);
|
|
260
|
-
// `err.message` already carries the specific failing resource
|
|
261
|
-
// when it comes from resolveAndValidateUrl (host / scheme),
|
|
262
|
-
// getExpectedChecksum (checksum entry lookup), the Checksum
|
|
263
|
-
// verification FAILED branch, or replaceBinaryAtomically
|
|
264
|
-
// (install + restore detail). Print it verbatim, and add the
|
|
265
|
-
// binary URL as the default download context for the manual
|
|
266
|
-
// fallback path.
|
|
267
|
-
console.error(`Failed to install renga: ${err.message}`);
|
|
268
|
-
console.error(`Binary URL: ${url}`);
|
|
269
|
-
console.error(`Checksums URL: ${baseUrl}/checksums.txt`);
|
|
270
|
-
console.error('You can download manually from: https://github.com/
|
|
271
|
-
process.exit(1);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
main();
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
|
|
6
|
+
const VERSION = require('../package.json').version;
|
|
7
|
+
const REPO = 'suisya-systems/renga';
|
|
8
|
+
const MAX_REDIRECTS = 5;
|
|
9
|
+
const ALLOWED_REDIRECT_HOSTS = new Set([
|
|
10
|
+
'github.com',
|
|
11
|
+
'objects.githubusercontent.com',
|
|
12
|
+
'release-assets.githubusercontent.com',
|
|
13
|
+
'github-releases.githubusercontent.com',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
function getPlatformBinary() {
|
|
17
|
+
const platform = process.platform;
|
|
18
|
+
const arch = process.arch;
|
|
19
|
+
|
|
20
|
+
if (platform === 'win32' && arch === 'x64') return 'renga-windows-x64.exe';
|
|
21
|
+
if (platform === 'darwin' && arch === 'arm64') return 'renga-macos-arm64';
|
|
22
|
+
if (platform === 'darwin' && arch === 'x64') return 'renga-macos-x64';
|
|
23
|
+
if (platform === 'linux' && arch === 'x64') return 'renga-linux-x64';
|
|
24
|
+
|
|
25
|
+
console.error(`Unsupported platform: ${platform}-${arch}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveAndValidateUrl(rawUrl, baseUrl) {
|
|
30
|
+
const parsed = new URL(rawUrl, baseUrl);
|
|
31
|
+
if (parsed.protocol !== 'https:') {
|
|
32
|
+
throw new Error(`Refusing non-HTTPS URL: ${parsed.toString()}`);
|
|
33
|
+
}
|
|
34
|
+
if (!ALLOWED_REDIRECT_HOSTS.has(parsed.hostname)) {
|
|
35
|
+
throw new Error(`Refusing download from unexpected host: ${parsed.hostname}`);
|
|
36
|
+
}
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function download(url, dest, redirects = 0, baseUrl) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
if (redirects > MAX_REDIRECTS) {
|
|
43
|
+
reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
let requestUrl;
|
|
47
|
+
try {
|
|
48
|
+
requestUrl = resolveAndValidateUrl(url, baseUrl);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
reject(err);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
https.get(requestUrl, { headers: { 'User-Agent': 'renga-installer' } }, (res) => {
|
|
54
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
55
|
+
download(res.headers.location, dest, redirects + 1, requestUrl).then(resolve, reject);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (res.statusCode !== 200) {
|
|
59
|
+
reject(new Error(`Download failed: HTTP ${res.statusCode}`));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const file = fs.createWriteStream(dest);
|
|
63
|
+
file.on('error', reject);
|
|
64
|
+
res.pipe(file);
|
|
65
|
+
file.on('finish', () => {
|
|
66
|
+
file.close();
|
|
67
|
+
resolve();
|
|
68
|
+
});
|
|
69
|
+
}).on('error', reject);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fetchText(url, redirects = 0, baseUrl) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
if (redirects > MAX_REDIRECTS) {
|
|
76
|
+
reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
let requestUrl;
|
|
80
|
+
try {
|
|
81
|
+
requestUrl = resolveAndValidateUrl(url, baseUrl);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
reject(err);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
https.get(requestUrl, { headers: { 'User-Agent': 'renga-installer' } }, (res) => {
|
|
87
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
88
|
+
fetchText(res.headers.location, redirects + 1, requestUrl).then(resolve, reject);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (res.statusCode !== 200) {
|
|
92
|
+
reject(new Error(`Fetch failed: HTTP ${res.statusCode}`));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
let data = '';
|
|
96
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
97
|
+
res.on('end', () => resolve(data));
|
|
98
|
+
}).on('error', reject);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sha256(filePath) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const hash = crypto.createHash('sha256');
|
|
105
|
+
const stream = fs.createReadStream(filePath);
|
|
106
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
107
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
108
|
+
stream.on('error', reject);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getExpectedChecksum(checksums, binaryName) {
|
|
113
|
+
for (const line of checksums.split(/\r?\n/)) {
|
|
114
|
+
const trimmed = line.trim();
|
|
115
|
+
if (!trimmed) continue;
|
|
116
|
+
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
|
|
117
|
+
if (!match) continue;
|
|
118
|
+
if (match[2] === binaryName) {
|
|
119
|
+
return match[1].toLowerCase();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`Checksum entry not found for ${binaryName}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function cleanupFile(filePath) {
|
|
126
|
+
try {
|
|
127
|
+
fs.rmSync(filePath, { force: true });
|
|
128
|
+
} catch (_) {
|
|
129
|
+
// Best-effort cleanup only.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Replace `dest` with `tempDest`.
|
|
134
|
+
//
|
|
135
|
+
// Not truly atomic: on Windows a POSIX-style rename isn't available,
|
|
136
|
+
// and even on Unix we run two renames back-to-back (dest -> backup,
|
|
137
|
+
// then tempDest -> dest), so there is a brief window where `dest`
|
|
138
|
+
// does not exist. If the process is killed inside that window the
|
|
139
|
+
// user is left with no binary at the canonical path until they
|
|
140
|
+
// rerun the installer.
|
|
141
|
+
//
|
|
142
|
+
// The sequence is still a strong improvement over the previous
|
|
143
|
+
// "rm old, rename new" path because:
|
|
144
|
+
//
|
|
145
|
+
// - The old binary is preserved at a unique backup path for as long
|
|
146
|
+
// as the install could fail. A rename-level failure restores it.
|
|
147
|
+
// - The backup path is per-install (suffix with process PID and a
|
|
148
|
+
// timestamp) so a stale `.bak` from a crashed previous run isn't
|
|
149
|
+
// clobbered, and a failed install leaves behind a file the user
|
|
150
|
+
// can inspect or restore manually.
|
|
151
|
+
// - The rename can fail on Windows when an antivirus scanner or
|
|
152
|
+
// another process briefly holds the old binary open (EBUSY /
|
|
153
|
+
// EPERM / EACCES). A small bounded retry with exponential
|
|
154
|
+
// backoff (50 / 100 / 200 / 400 ms between attempts, five
|
|
155
|
+
// attempts total, ~750 ms total wait before giving up) absorbs
|
|
156
|
+
// most of that transient lock contention.
|
|
157
|
+
function replaceBinaryWithBackup(tempDest, dest) {
|
|
158
|
+
const backup = `${dest}.${process.pid}.${Date.now()}.bak`;
|
|
159
|
+
const hadExisting = fs.existsSync(dest);
|
|
160
|
+
|
|
161
|
+
if (hadExisting) {
|
|
162
|
+
fs.renameSync(dest, backup);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const maxAttempts = 5;
|
|
166
|
+
let lastErr = null;
|
|
167
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
168
|
+
try {
|
|
169
|
+
fs.renameSync(tempDest, dest);
|
|
170
|
+
lastErr = null;
|
|
171
|
+
break;
|
|
172
|
+
} catch (err) {
|
|
173
|
+
lastErr = err;
|
|
174
|
+
const retryable =
|
|
175
|
+
err && (err.code === 'EBUSY' || err.code === 'EPERM' || err.code === 'EACCES');
|
|
176
|
+
if (!retryable || attempt === maxAttempts) break;
|
|
177
|
+
const waitMs = 50 * Math.pow(2, attempt - 1);
|
|
178
|
+
const until = Date.now() + waitMs;
|
|
179
|
+
while (Date.now() < until) {
|
|
180
|
+
// Busy-wait is fine here; install.js is short-lived and
|
|
181
|
+
// single-threaded, and we want to stay synchronous.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (lastErr) {
|
|
187
|
+
if (hadExisting) {
|
|
188
|
+
try {
|
|
189
|
+
fs.renameSync(backup, dest);
|
|
190
|
+
} catch (restoreErr) {
|
|
191
|
+
lastErr = new Error(
|
|
192
|
+
`Failed to install new binary and failed to restore old binary.\n` +
|
|
193
|
+
` Install error: ${lastErr.message}\n` +
|
|
194
|
+
` Restore error: ${restoreErr.message}\n` +
|
|
195
|
+
` Backup lives at: ${backup}`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
throw lastErr;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (hadExisting) {
|
|
203
|
+
cleanupFile(backup);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function main() {
|
|
208
|
+
const binaryName = getPlatformBinary();
|
|
209
|
+
const baseUrl = `https://github.com/${REPO}/releases/download/v${VERSION}`;
|
|
210
|
+
const url = `${baseUrl}/${binaryName}`;
|
|
211
|
+
const binDir = path.join(__dirname, '..', 'bin');
|
|
212
|
+
const isWindows = process.platform === 'win32';
|
|
213
|
+
const dest = path.join(binDir, isWindows ? 'renga.exe' : 'renga');
|
|
214
|
+
const tempDest = `${dest}.tmp`;
|
|
215
|
+
|
|
216
|
+
console.log(`Downloading renga v${VERSION} for ${process.platform}-${process.arch}...`);
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
220
|
+
cleanupFile(tempDest);
|
|
221
|
+
|
|
222
|
+
await download(url, tempDest);
|
|
223
|
+
|
|
224
|
+
const checksums = await fetchText(`${baseUrl}/checksums.txt`);
|
|
225
|
+
const expectedHash = getExpectedChecksum(checksums, binaryName);
|
|
226
|
+
const actualHash = await sha256(tempDest);
|
|
227
|
+
if (actualHash !== expectedHash) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
[
|
|
230
|
+
'Checksum verification FAILED - downloaded binary does not match.',
|
|
231
|
+
` Expected: ${expectedHash}`,
|
|
232
|
+
` Actual: ${actualHash}`,
|
|
233
|
+
].join('\n')
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
console.log('Checksum verified.');
|
|
237
|
+
|
|
238
|
+
if (!isWindows) {
|
|
239
|
+
fs.chmodSync(tempDest, 0o755);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
replaceBinaryWithBackup(tempDest, dest);
|
|
243
|
+
|
|
244
|
+
const BLUE = '\x1b[38;2;88;166;255m';
|
|
245
|
+
const DIM = '\x1b[38;2;110;118;129m';
|
|
246
|
+
const RESET = '\x1b[0m';
|
|
247
|
+
console.log('');
|
|
248
|
+
console.log(`${BLUE}██████╗ ███████╗███╗ ██╗ ██████╗ █████╗ ${RESET}`);
|
|
249
|
+
console.log(`${BLUE}██╔══██╗██╔════╝████╗ ██║██╔════╝ ██╔══██╗${RESET}`);
|
|
250
|
+
console.log(`${BLUE}██████╔╝█████╗ ██╔██╗ ██║██║ ███╗███████║${RESET}`);
|
|
251
|
+
console.log(`${BLUE}██╔══██╗██╔══╝ ██║╚██╗██║██║ ██║██╔══██║${RESET}`);
|
|
252
|
+
console.log(`${BLUE}██║ ██║███████╗██║ ╚████║╚██████╔╝██║ ██║${RESET}`);
|
|
253
|
+
console.log(`${BLUE}╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝${RESET}`);
|
|
254
|
+
console.log('');
|
|
255
|
+
console.log(`${DIM} Claude Code Multiplexer v${VERSION}${RESET}`);
|
|
256
|
+
console.log(`${DIM} Run 'renga' to start.${RESET}`);
|
|
257
|
+
console.log('');
|
|
258
|
+
} catch (err) {
|
|
259
|
+
cleanupFile(tempDest);
|
|
260
|
+
// `err.message` already carries the specific failing resource
|
|
261
|
+
// when it comes from resolveAndValidateUrl (host / scheme),
|
|
262
|
+
// getExpectedChecksum (checksum entry lookup), the Checksum
|
|
263
|
+
// verification FAILED branch, or replaceBinaryAtomically
|
|
264
|
+
// (install + restore detail). Print it verbatim, and add the
|
|
265
|
+
// binary URL as the default download context for the manual
|
|
266
|
+
// fallback path.
|
|
267
|
+
console.error(`Failed to install renga: ${err.message}`);
|
|
268
|
+
console.error(`Binary URL: ${url}`);
|
|
269
|
+
console.error(`Checksums URL: ${baseUrl}/checksums.txt`);
|
|
270
|
+
console.error('You can download manually from: https://github.com/suisya-systems/renga/releases');
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
main();
|