buddy-reroll 0.3.3 → 0.3.4

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/index.js CHANGED
@@ -40,8 +40,12 @@ if (!IS_BUN && !IS_APPLY_HOOK) {
40
40
  }
41
41
 
42
42
  function getUserId(configPath) {
43
- const config = JSON.parse(readFileSync(configPath, "utf-8"));
44
- return config.oauthAccount?.accountUuid ?? config.userID ?? "anon";
43
+ try {
44
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
45
+ return config.oauthAccount?.accountUuid ?? config.userID ?? "anon";
46
+ } catch {
47
+ return "anon";
48
+ }
45
49
  }
46
50
 
47
51
  // ── Binary patch ─────────────────────────────────────────────────────────
@@ -93,29 +97,24 @@ function patchBinary(binaryPath, oldSalt, newSalt) {
93
97
  if (count === 0) throw new Error(`Salt "${oldSalt}" not found in binary`);
94
98
 
95
99
  const isWin = platform() === "win32";
100
+ const tmpPath = binaryPath + ".tmp";
96
101
  const maxRetries = isWin ? 3 : 1;
102
+
97
103
  for (let attempt = 0; attempt < maxRetries; attempt++) {
98
104
  try {
99
- const tmpPath = binaryPath + ".tmp";
100
105
  writeFileSync(tmpPath, data, { mode: originalMode });
101
- try {
102
- renameSync(tmpPath, binaryPath);
103
- } catch {
104
- if (existsSync(binaryPath + ".backup")) {
105
- try { unlinkSync(binaryPath); } catch {}
106
- }
107
- renameSync(tmpPath, binaryPath);
108
- }
109
106
 
110
- chmodSync(binaryPath, originalMode);
111
-
112
- const verify = readFileSync(binaryPath);
113
- const found = verify.indexOf(Buffer.from(newSalt));
114
- if (found === -1) throw new Error("Patch verification failed — new salt not found after write");
107
+ const verify = readFileSync(tmpPath);
108
+ if (verify.indexOf(Buffer.from(newSalt)) === -1) {
109
+ try { unlinkSync(tmpPath); } catch {}
110
+ throw new Error("Patch verification failed — new salt not found in temp file");
111
+ }
115
112
 
113
+ renameSync(tmpPath, binaryPath);
114
+ try { chmodSync(binaryPath, originalMode); } catch {}
116
115
  return count;
117
116
  } catch (err) {
118
- try { unlinkSync(binaryPath + ".tmp"); } catch {}
117
+ try { unlinkSync(tmpPath); } catch {}
119
118
  if (isWin && (err.code === "EACCES" || err.code === "EPERM" || err.code === "EBUSY") && attempt < maxRetries - 1) {
120
119
  sleepMs(2000);
121
120
  continue;
@@ -123,7 +122,7 @@ function patchBinary(binaryPath, oldSalt, newSalt) {
123
122
  if (isWin && (err.code === "EPERM" || err.code === "EBUSY")) {
124
123
  throw new Error("Can't write — Claude Code might still be running. Close it and try again.");
125
124
  }
126
- throw new Error(`Failed to write: ${err.message}`);
125
+ throw err;
127
126
  }
128
127
  }
129
128
  }
@@ -143,12 +142,18 @@ function resignBinary(binaryPath) {
143
142
  }
144
143
 
145
144
  function clearCompanion(configPath) {
146
- const raw = readFileSync(configPath, "utf-8");
147
- const config = JSON.parse(raw);
148
- delete config.companion;
149
- delete config.companionMuted;
150
- const indent = raw.match(/^(\s+)"/m)?.[1] ?? " ";
151
- writeFileSync(configPath, JSON.stringify(config, null, indent) + "\n");
145
+ try {
146
+ const raw = readFileSync(configPath, "utf-8");
147
+ const config = JSON.parse(raw);
148
+ if (!config.companion && !config.companionMuted) return;
149
+ delete config.companion;
150
+ delete config.companionMuted;
151
+ const indent = raw.match(/^(\s+)"/m)?.[1] ?? " ";
152
+ const mode = statSync(configPath).mode;
153
+ const tmpPath = configPath + ".tmp";
154
+ writeFileSync(tmpPath, JSON.stringify(config, null, indent) + "\n", { mode });
155
+ renameSync(tmpPath, configPath);
156
+ } catch {}
152
157
  }
153
158
 
154
159
  function fail(message) {
@@ -460,13 +465,13 @@ async function main() {
460
465
  }
461
466
 
462
467
  if (args["apply-hook"]) {
468
+ let mutated = false;
463
469
  try {
464
470
  const stored = readStoredSalt();
465
471
  if (!stored) process.exit(0);
466
472
  const bp = findBinaryPath();
467
473
  const cp = findConfigPath();
468
474
  if (!bp || !cp) process.exit(0);
469
- const uid = getUserId(cp);
470
475
  const binaryData = readFileSync(bp);
471
476
  const currentSalt = findCurrentSalt(binaryData);
472
477
  if (!currentSalt) process.exit(0);
@@ -476,16 +481,21 @@ async function main() {
476
481
  const backupPath = patchability.backupPath;
477
482
  if (!existsSync(backupPath)) copyFileSync(bp, backupPath);
478
483
  patchBinary(bp, currentSalt, stored.salt);
484
+ mutated = true;
479
485
  if (platform() === "darwin") {
480
486
  try {
481
487
  execFileSync("codesign", ["-s", "-", "--force", bp], { stdio: "ignore", timeout: 30000 });
482
488
  } catch {
483
489
  copyFileSync(backupPath, bp);
490
+ try { chmodSync(bp, statSync(backupPath).mode); } catch {}
484
491
  process.exit(1);
485
492
  }
486
493
  }
487
494
  clearCompanion(cp);
488
- } catch {}
495
+ } catch (err) {
496
+ process.stderr.write(`buddy-reroll --apply-hook failed: ${err.message}\n`);
497
+ process.exit(mutated ? 1 : 0);
498
+ }
489
499
  process.exit(0);
490
500
  }
491
501
 
package/lib/finder.js CHANGED
@@ -7,7 +7,7 @@ import { rollFrom } from "./companion.js";
7
7
 
8
8
  const WORKER_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "..", "scripts", "worker.js");
9
9
 
10
- export async function parallelBruteForce(userId, target, onProgress) {
10
+ export async function parallelBruteForce(userId, target, onProgress, signal) {
11
11
  const numWorkers = Math.max(1, Math.min(cpus().length, 8));
12
12
  const expected = estimateAttempts(target);
13
13
 
@@ -18,13 +18,25 @@ export async function parallelBruteForce(userId, target, onProgress) {
18
18
  const workerAttempts = [];
19
19
  let resolved = false;
20
20
  let exited = 0;
21
+ let timer;
22
+
23
+ function finish(fn, val) {
24
+ if (resolved) return;
25
+ resolved = true;
26
+ clearTimeout(timer);
27
+ killAll();
28
+ fn(val);
29
+ }
21
30
 
22
31
  function killAll() {
23
32
  for (const child of children) {
24
- try { child.kill(); } catch {}
33
+ try { child.kill("SIGKILL"); } catch {}
25
34
  }
26
35
  }
27
36
 
37
+ if (signal?.aborted) { reject(new Error("Cancelled")); return; }
38
+ signal?.addEventListener("abort", () => finish(reject, new Error("Cancelled")), { once: true });
39
+
28
40
  for (let i = 0; i < numWorkers; i++) {
29
41
  workerStdout[i] = "";
30
42
  workerStderr[i] = "";
@@ -50,9 +62,7 @@ export async function parallelBruteForce(userId, target, onProgress) {
50
62
  workerAttempts[i] = progress.attempts;
51
63
  const totalAttempts = workerAttempts.reduce((a, b) => a + b, 0);
52
64
  const elapsed = progress.elapsed || 0;
53
- if (onProgress) {
54
- onProgress(totalAttempts, elapsed, expected, numWorkers);
55
- }
65
+ if (onProgress) onProgress(totalAttempts, elapsed, expected, numWorkers);
56
66
  }
57
67
  } catch {}
58
68
  }
@@ -63,14 +73,11 @@ export async function parallelBruteForce(userId, target, onProgress) {
63
73
  if (resolved) return;
64
74
 
65
75
  if (code === 0 && workerStdout[i].trim()) {
66
- resolved = true;
67
- killAll();
68
-
69
76
  try {
70
77
  const result = JSON.parse(workerStdout[i].trim());
71
78
  const totalAttempts = workerAttempts.reduce((a, b) => a + b, 0);
72
79
  const roll = rollFrom(result.salt, userId);
73
- resolve({
80
+ finish(resolve, {
74
81
  salt: result.salt,
75
82
  result: roll,
76
83
  checked: Math.max(totalAttempts, result.attempts),
@@ -78,35 +85,31 @@ export async function parallelBruteForce(userId, target, onProgress) {
78
85
  workers: numWorkers,
79
86
  });
80
87
  } catch (err) {
81
- reject(err);
88
+ finish(reject, err);
82
89
  }
83
90
  return;
84
91
  }
85
92
 
86
- if (exited === numWorkers && !resolved) {
93
+ if (exited === numWorkers) {
87
94
  const totalAttempts = workerAttempts.reduce((a, b) => a + b, 0);
88
95
  const stderr = workerStderr.filter(Boolean).join("\n").trim();
89
96
  const detail = stderr ? `\n Worker output: ${stderr.slice(0, 200)}` : "";
90
- reject(new Error(`All ${numWorkers} workers exited without finding a match (${totalAttempts.toLocaleString()} tries).${detail}`));
97
+ finish(reject, new Error(`All ${numWorkers} workers exited without finding a match (${totalAttempts.toLocaleString()} tries).${detail}`));
91
98
  }
92
99
  });
93
100
 
94
101
  child.on("error", (err) => {
95
102
  exited++;
96
103
  if (exited === numWorkers && !resolved) {
97
- reject(new Error(`Worker failed to start: ${err.message}`));
104
+ finish(reject, new Error(`Worker failed to start: ${err.message}`));
98
105
  }
99
106
  });
100
107
  }
101
108
 
102
109
  const timeoutMs = Math.max(600_000, Math.ceil(expected / 50_000_000) * 60_000 + 600_000);
103
- setTimeout(() => {
104
- if (!resolved) {
105
- resolved = true;
106
- killAll();
107
- const totalAttempts = workerAttempts.reduce((a, b) => a + b, 0);
108
- reject(new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s (${totalAttempts.toLocaleString()} tries). This combination might be extremely rare — try fewer constraints.`));
109
- }
110
+ timer = setTimeout(() => {
111
+ const totalAttempts = workerAttempts.reduce((a, b) => a + b, 0);
112
+ finish(reject, new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s (${totalAttempts.toLocaleString()} tries). This combination might be extremely rare — try fewer constraints.`));
110
113
  }, timeoutMs);
111
114
  });
112
115
  }
package/lib/hooks.js CHANGED
@@ -1,11 +1,10 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, statSync } from "fs";
2
2
  import { join, dirname } from "path";
3
3
  import { homedir } from "os";
4
4
 
5
5
  const HOOK_COMMAND = "npx buddy-reroll --apply-hook";
6
6
 
7
7
  const HOOK_ENTRY = {
8
- matcher: "",
9
8
  hooks: [{ type: "command", command: HOOK_COMMAND }],
10
9
  };
11
10
 
@@ -24,18 +23,28 @@ export function readSettings(settingsPath) {
24
23
  try {
25
24
  return JSON.parse(readFileSync(settingsPath, "utf-8"));
26
25
  } catch {
27
- return {};
26
+ return null;
28
27
  }
29
28
  }
30
29
 
31
- export function writeSettings(settingsPath, obj) {
30
+ function writeSettingsAtomic(settingsPath, obj) {
32
31
  const dir = dirname(settingsPath);
33
32
  mkdirSync(dir, { recursive: true });
34
- writeFileSync(settingsPath, JSON.stringify(obj, null, 2) + "\n");
33
+ const tmpPath = settingsPath + ".tmp";
34
+ const content = JSON.stringify(obj, null, 2) + "\n";
35
+ let mode;
36
+ try { mode = statSync(settingsPath).mode; } catch {}
37
+ writeFileSync(tmpPath, content, mode ? { mode } : undefined);
38
+ renameSync(tmpPath, settingsPath);
39
+ }
40
+
41
+ export function writeSettings(settingsPath, obj) {
42
+ writeSettingsAtomic(settingsPath, obj);
35
43
  }
36
44
 
37
45
  export function installHook(settingsPath = getSettingsPath()) {
38
46
  const settings = readSettings(settingsPath);
47
+ if (!settings) return { installed: false, reason: "settings file is corrupted, not modifying" };
39
48
 
40
49
  if (!settings.hooks) settings.hooks = {};
41
50
  if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
@@ -47,13 +56,14 @@ export function installHook(settingsPath = getSettingsPath()) {
47
56
 
48
57
  settings.hooks.SessionStart = settings.hooks.SessionStart.filter((e) => !isOurHook(e));
49
58
  settings.hooks.SessionStart.push(HOOK_ENTRY);
50
- writeSettings(settingsPath, settings);
59
+ writeSettingsAtomic(settingsPath, settings);
51
60
 
52
61
  return { installed: true, path: settingsPath };
53
62
  }
54
63
 
55
64
  export function removeHook(settingsPath = getSettingsPath()) {
56
65
  const settings = readSettings(settingsPath);
66
+ if (!settings) return { removed: false, reason: "settings file is corrupted, not modifying" };
57
67
 
58
68
  if (!settings.hooks || !Array.isArray(settings.hooks.SessionStart)) {
59
69
  return { removed: false, reason: "not installed" };
@@ -69,12 +79,13 @@ export function removeHook(settingsPath = getSettingsPath()) {
69
79
  if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart;
70
80
  if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
71
81
 
72
- writeSettings(settingsPath, settings);
82
+ writeSettingsAtomic(settingsPath, settings);
73
83
  return { removed: true, path: settingsPath };
74
84
  }
75
85
 
76
86
  export function isHookInstalled(settingsPath = getSettingsPath()) {
77
87
  const settings = readSettings(settingsPath);
88
+ if (!settings) return false;
78
89
  return Array.isArray(settings.hooks?.SessionStart) && settings.hooks.SessionStart.some(isOurHook);
79
90
  }
80
91
 
@@ -86,7 +97,9 @@ export function storeSalt(salt) {
86
97
  const path = getSaltStorePath();
87
98
  const dir = dirname(path);
88
99
  mkdirSync(dir, { recursive: true });
89
- writeFileSync(path, JSON.stringify({ salt, timestamp: Date.now() }, null, 2) + "\n");
100
+ const tmpPath = path + ".tmp";
101
+ writeFileSync(tmpPath, JSON.stringify({ salt, timestamp: Date.now() }, null, 2) + "\n");
102
+ renameSync(tmpPath, path);
90
103
  }
91
104
 
92
105
  export function readStoredSalt() {
package/lib/hooks.test.js CHANGED
@@ -45,10 +45,10 @@ describe("readSettings", () => {
45
45
  expect(readSettings(p)).toEqual({ permissions: { allow: [] } });
46
46
  });
47
47
 
48
- it("returns empty object for corrupted JSON", () => {
48
+ it("returns null for corrupted JSON", () => {
49
49
  const p = join(tempDir, "settings.json");
50
50
  writeFileSync(p, "{ invalid json");
51
- expect(readSettings(p)).toEqual({});
51
+ expect(readSettings(p)).toBeNull();
52
52
  });
53
53
  });
54
54
 
@@ -76,7 +76,7 @@ describe("installHook", () => {
76
76
  const settings = readSettings(p);
77
77
  const hook = findOurHook(settings);
78
78
  expect(hook).toBeDefined();
79
- expect(hook.matcher).toBe("");
79
+ expect(hook.matcher).toBeUndefined();
80
80
  expect(hook.hooks[0].type).toBe("command");
81
81
  expect(hook.hooks[0].command).toBe(HOOK_COMMAND);
82
82
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-reroll",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Reroll your Claude Code buddy companion to any species/rarity/eye/hat/shiny combo",
5
5
  "type": "module",
6
6
  "bin": {
package/ui.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useRef } from "react";
1
+ import React, { useState, useEffect, useRef, useCallback } from "react";
2
2
  import { render, Box, Text, useApp, useInput } from "ink";
3
3
  import { renderSprite, RARITY_STARS, RARITY_COLORS } from "./sprites.js";
4
4
  import { existsSync, copyFileSync } from "fs";
@@ -125,19 +125,41 @@ function PreviewCard({ species, rarity, eye, hat, shiny, stats }) {
125
125
  );
126
126
  }
127
127
 
128
- function ShowCurrentStep({ isActive }) {
128
+ function ExitOnKey({ isActive, children }) {
129
129
  const { exit } = useApp();
130
+ const exiting = useRef(false);
130
131
 
131
- useInput(() => {
132
+ const doExit = useCallback(() => {
133
+ if (exiting.current) return;
134
+ exiting.current = true;
132
135
  exit();
133
- setTimeout(() => process.exit(0), 100);
134
- }, { isActive });
136
+ setTimeout(() => process.exit(0), 200);
137
+ }, [exit]);
138
+
139
+ useInput(() => doExit(), { isActive });
140
+
141
+ useEffect(() => {
142
+ if (!isActive) return;
143
+ const fallback = () => doExit();
144
+ process.stdin.once("data", fallback);
145
+ const timer = setTimeout(() => doExit(), 30000);
146
+ return () => {
147
+ process.stdin.removeListener("data", fallback);
148
+ clearTimeout(timer);
149
+ };
150
+ }, [isActive, doExit]);
151
+
152
+ return children;
153
+ }
135
154
 
155
+ function ShowCurrentStep({ isActive }) {
136
156
  return (
137
- <Box flexDirection="column">
138
- <Text color="green">✓ Current companion shown above.</Text>
139
- <KeyHint>Press any key to exit</KeyHint>
140
- </Box>
157
+ <ExitOnKey isActive={isActive}>
158
+ <Box flexDirection="column">
159
+ <Text color="green">✓ Current companion shown above.</Text>
160
+ <KeyHint>Press any key to exit</KeyHint>
161
+ </Box>
162
+ </ExitOnKey>
141
163
  );
142
164
  }
143
165
 
@@ -169,40 +191,44 @@ function SpeciesStep({ speciesList, current, onChange, onSubmit, onBack, isActiv
169
191
 
170
192
  function SearchStep({ userId, target, bruteForce, onFound, onFail, isActive }) {
171
193
  const [progress, setProgress] = useState("");
172
- const cancelRef = useRef(false);
194
+ const abortRef = useRef(null);
173
195
  const hasStarted = useRef(false);
174
196
  const { exit } = useApp();
175
197
 
176
198
  useInput((input, key) => {
177
199
  if (key.escape) {
178
- cancelRef.current = true;
200
+ if (abortRef.current) abortRef.current.abort();
179
201
  exit();
202
+ setTimeout(() => process.exit(0), 200);
180
203
  }
181
204
  }, { isActive });
182
205
 
183
206
  useEffect(() => {
184
207
  if (hasStarted.current) return;
185
208
  hasStarted.current = true;
209
+ const ac = new AbortController();
210
+ abortRef.current = ac;
186
211
  (async () => {
187
212
  let found;
188
213
  try {
189
214
  found = await bruteForce(userId, target, (attempts, elapsed, expected, workers) => {
190
- if (!cancelRef.current) {
215
+ if (!ac.signal.aborted) {
191
216
  const pct = Math.min(100, Math.round((attempts / expected) * 100));
192
217
  const rate = attempts / (elapsed / 1000);
193
218
  const rateStr = rate >= 1e6 ? `${(rate / 1e6).toFixed(1)}M` : `${(rate / 1e3).toFixed(1)}k`;
194
219
  const eta = Math.max(0, (expected - attempts) / rate);
195
220
  setProgress(`${pct}% | ${rateStr} tries/s | ~${Math.round(eta)}s left | ${workers} cores`);
196
221
  }
197
- });
222
+ }, ac.signal);
198
223
  } catch {
199
- if (!cancelRef.current) onFail();
224
+ if (!ac.signal.aborted) onFail();
200
225
  return;
201
226
  }
202
- if (cancelRef.current) return;
227
+ if (ac.signal.aborted) return;
203
228
  if (found) onFound(found);
204
229
  else onFail();
205
230
  })();
231
+ return () => ac.abort();
206
232
  }, [bruteForce, userId, target, onFound, onFail]);
207
233
 
208
234
  return (
@@ -214,30 +240,26 @@ function SearchStep({ userId, target, bruteForce, onFound, onFail, isActive }) {
214
240
  }
215
241
 
216
242
  function DoneStep({ messages, isActive }) {
217
- const { exit } = useApp();
218
243
  const hasErrors = messages.some((msg) => msg.type === "error");
219
244
 
220
- useInput(() => {
221
- exit();
222
- setTimeout(() => process.exit(0), 100);
223
- }, { isActive });
224
-
225
245
  return (
226
- <Box flexDirection="column">
227
- {messages.map((msg) => (
228
- <Text key={`${msg.type}-${msg.text}`} color={msg.type === "error" ? "red" : "green"}>
229
- {msg.type === "error" ? "" : ""}{msg.text}
230
- </Text>
231
- ))}
232
- <Box marginTop={1}>
233
- <Text bold>
234
- {hasErrors
235
- ? "Something went wrong — check the issue above and try again."
236
- : "All set! Your buddy will stick around even after updates. Restart Claude Code and say /buddy!"}
237
- </Text>
246
+ <ExitOnKey isActive={isActive}>
247
+ <Box flexDirection="column">
248
+ {messages.map((msg) => (
249
+ <Text key={`${msg.type}-${msg.text}`} color={msg.type === "error" ? "red" : "green"}>
250
+ {msg.type === "error" ? "✗ " : "✓ "}{msg.text}
251
+ </Text>
252
+ ))}
253
+ <Box marginTop={1}>
254
+ <Text bold>
255
+ {hasErrors
256
+ ? "Something went wrong check the issue above and try again."
257
+ : "All set! Your buddy will stick around even after updates. Restart Claude Code and say /buddy!"}
258
+ </Text>
259
+ </Box>
260
+ <KeyHint>Press any key to exit</KeyHint>
238
261
  </Box>
239
- <KeyHint>Press any key to exit</KeyHint>
240
- </Box>
262
+ </ExitOnKey>
241
263
  );
242
264
  }
243
265