@suisya-systems/renga 0.18.1 → 0.18.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.
package/README.md CHANGED
@@ -1,49 +1,49 @@
1
- # @suisya-systems/renga
2
-
3
- Claude Code Multiplexer (fork) — manage multiple Claude Code instances in TUI split panes.
4
-
5
- > This is a fork of [Shin-sibainu/ccmux](https://github.com/Shin-sibainu/ccmux) published as `@suisya-systems/renga` (previously `renga-fork`). It develops independent features while periodically syncing upstream.
6
-
7
- ## Install
8
-
9
- ```bash
10
- npm install -g @suisya-systems/renga
11
- ```
12
-
13
- Migrating from previous `renga-fork`:
14
-
15
- ```bash
16
- npm uninstall -g renga-fork && npm install -g @suisya-systems/renga
17
- ```
18
-
19
- Migrating from the upstream `renga-cli`:
20
-
21
- ```bash
22
- npm uninstall -g renga-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 (this fork)](https://github.com/happy-ryo/ccmux)
45
- - [Upstream](https://github.com/Shin-sibainu/ccmux)
46
-
47
- ## License
48
-
49
- MIT
1
+ # @suisya-systems/renga
2
+
3
+ Claude Code Multiplexer (fork) — manage multiple Claude Code instances in TUI split panes.
4
+
5
+ > This is a fork of [Shin-sibainu/ccmux](https://github.com/Shin-sibainu/ccmux) published as `@suisya-systems/renga` (previously `ccmux-fork`). It develops independent features while periodically syncing upstream.
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 `renga-cli`:
20
+
21
+ ```bash
22
+ npm uninstall -g renga-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 (this fork)](https://github.com/suisya-systems/renga)
45
+ - [Upstream](https://github.com/Shin-sibainu/ccmux)
46
+
47
+ ## License
48
+
49
+ MIT
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.1",
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/happy-ryo/ccmux"
29
- },
30
- "homepage": "https://github.com/happy-ryo/ccmux",
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.2",
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
+ }
@@ -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 = 'happy-ryo/ccmux';
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/happy-ryo/ccmux/releases');
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();