buddy-reroll 0.3.2 → 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
-
110
- chmodSync(binaryPath, originalMode);
111
106
 
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,21 +481,41 @@ 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
 
492
502
  if (args.doctor) {
493
- console.log(`\n${formatDoctorReport(getDoctorReport(), "buddy-reroll doctor")}\n`);
503
+ const report = getDoctorReport();
504
+ if (report.status === "not-executable" && report.binaryPath) {
505
+ try {
506
+ const { chmodSync, statSync } = await import("fs");
507
+ const mode = statSync(report.binaryPath).mode | 0o111;
508
+ chmodSync(report.binaryPath, mode);
509
+ console.log(`\n ✓ Fixed: restored execute permission on ${report.binaryPath}`);
510
+ const fixed = getDoctorReport();
511
+ console.log(`\n${formatDoctorReport(fixed, "buddy-reroll doctor")}\n`);
512
+ } catch (err) {
513
+ console.log(`\n${formatDoctorReport(report, "buddy-reroll doctor")}\n`);
514
+ console.log(` ⚠ Auto-fix failed: ${err.message}\n Run manually: chmod +x "${report.binaryPath}"\n`);
515
+ }
516
+ } else {
517
+ console.log(`\n${formatDoctorReport(report, "buddy-reroll doctor")}\n`);
518
+ }
494
519
  return;
495
520
  }
496
521
 
package/lib/doctor.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { accessSync, constants, chmodSync, statSync } from "fs";
1
2
  import { getClaudeBinaryOverride, getClaudeConfigDir, findBinaryPath, findConfigPath, getPatchability } from "./runtime.js";
2
3
 
3
4
  function buildStatus(binaryPath, configPath, patchability) {
@@ -5,6 +6,11 @@ function buildStatus(binaryPath, configPath, patchability) {
5
6
  if (!binaryPath) return "missing-binary";
6
7
  if (!configPath) return "missing-config";
7
8
  if (!patchability.ok) return "read-only";
9
+ try {
10
+ accessSync(binaryPath, constants.X_OK);
11
+ } catch {
12
+ return "not-executable";
13
+ }
8
14
  return "ready";
9
15
  }
10
16
 
@@ -18,6 +24,8 @@ function buildNextStep(status) {
18
24
  return "Open Claude Code once so it writes config, or point `CLAUDE_CONFIG_DIR` to the correct config directory.";
19
25
  case "read-only":
20
26
  return "Use a user-writable Claude install, or point `CLAUDE_BINARY_PATH` to a writable Claude binary copy; `--current` can still work with a read-only install.";
27
+ case "not-executable":
28
+ return "Claude binary lost its execute permission (likely from a patch). Run: chmod +x <binary-path>";
21
29
  default:
22
30
  return "`--current` and reroll commands are ready to run on this machine.";
23
31
  }
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,75 +1,92 @@
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
+ const HOOK_ENTRY = {
8
+ hooks: [{ type: "command", command: HOOK_COMMAND }],
9
+ };
10
+
11
+ function isOurHook(entry) {
12
+ if (typeof entry === "string") return entry === HOOK_COMMAND;
13
+ if (entry?.hooks) return entry.hooks.some((h) => h.command === HOOK_COMMAND);
14
+ return false;
15
+ }
16
+
7
17
  export function getSettingsPath() {
8
18
  return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "settings.json");
9
19
  }
10
20
 
11
21
  export function readSettings(settingsPath) {
12
- if (!existsSync(settingsPath)) {
13
- return {};
14
- }
22
+ if (!existsSync(settingsPath)) return {};
15
23
  try {
16
24
  return JSON.parse(readFileSync(settingsPath, "utf-8"));
17
25
  } catch {
18
- return {};
26
+ return null;
19
27
  }
20
28
  }
21
29
 
22
- export function writeSettings(settingsPath, obj) {
30
+ function writeSettingsAtomic(settingsPath, obj) {
23
31
  const dir = dirname(settingsPath);
24
32
  mkdirSync(dir, { recursive: true });
25
- 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);
26
43
  }
27
44
 
28
45
  export function installHook(settingsPath = getSettingsPath()) {
29
46
  const settings = readSettings(settingsPath);
30
-
31
- if (!settings.hooks) {
32
- settings.hooks = {};
33
- }
34
- if (!settings.hooks.SessionStart) {
35
- settings.hooks.SessionStart = [];
36
- }
37
-
38
- if (settings.hooks.SessionStart.includes(HOOK_COMMAND)) {
47
+ if (!settings) return { installed: false, reason: "settings file is corrupted, not modifying" };
48
+
49
+ if (!settings.hooks) settings.hooks = {};
50
+ if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
51
+
52
+ const existing = settings.hooks.SessionStart.find(isOurHook);
53
+ if (existing && typeof existing === "object") {
39
54
  return { installed: false, reason: "already installed" };
40
55
  }
41
-
42
- settings.hooks.SessionStart.push(HOOK_COMMAND);
43
- writeSettings(settingsPath, settings);
44
-
56
+
57
+ settings.hooks.SessionStart = settings.hooks.SessionStart.filter((e) => !isOurHook(e));
58
+ settings.hooks.SessionStart.push(HOOK_ENTRY);
59
+ writeSettingsAtomic(settingsPath, settings);
60
+
45
61
  return { installed: true, path: settingsPath };
46
62
  }
47
63
 
48
64
  export function removeHook(settingsPath = getSettingsPath()) {
49
65
  const settings = readSettings(settingsPath);
50
-
51
- if (!settings.hooks || !settings.hooks.SessionStart) {
66
+ if (!settings) return { removed: false, reason: "settings file is corrupted, not modifying" };
67
+
68
+ if (!settings.hooks || !Array.isArray(settings.hooks.SessionStart)) {
52
69
  return { removed: false, reason: "not installed" };
53
70
  }
54
-
55
- settings.hooks.SessionStart = settings.hooks.SessionStart.filter(cmd => cmd !== HOOK_COMMAND);
56
-
57
- if (settings.hooks.SessionStart.length === 0) {
58
- delete settings.hooks.SessionStart;
59
- }
60
-
61
- if (Object.keys(settings.hooks).length === 0) {
62
- delete settings.hooks;
71
+
72
+ const before = settings.hooks.SessionStart.length;
73
+ settings.hooks.SessionStart = settings.hooks.SessionStart.filter((e) => !isOurHook(e));
74
+
75
+ if (settings.hooks.SessionStart.length === before) {
76
+ return { removed: false, reason: "not installed" };
63
77
  }
64
-
65
- writeSettings(settingsPath, settings);
66
-
78
+
79
+ if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart;
80
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
81
+
82
+ writeSettingsAtomic(settingsPath, settings);
67
83
  return { removed: true, path: settingsPath };
68
84
  }
69
85
 
70
86
  export function isHookInstalled(settingsPath = getSettingsPath()) {
71
87
  const settings = readSettings(settingsPath);
72
- return settings.hooks?.SessionStart?.includes(HOOK_COMMAND) ?? false;
88
+ if (!settings) return false;
89
+ return Array.isArray(settings.hooks?.SessionStart) && settings.hooks.SessionStart.some(isOurHook);
73
90
  }
74
91
 
75
92
  export function getSaltStorePath() {
@@ -80,14 +97,14 @@ export function storeSalt(salt) {
80
97
  const path = getSaltStorePath();
81
98
  const dir = dirname(path);
82
99
  mkdirSync(dir, { recursive: true });
83
- 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);
84
103
  }
85
104
 
86
105
  export function readStoredSalt() {
87
106
  const path = getSaltStorePath();
88
- if (!existsSync(path)) {
89
- return null;
90
- }
107
+ if (!existsSync(path)) return null;
91
108
  try {
92
109
  return JSON.parse(readFileSync(path, "utf-8"));
93
110
  } catch {
package/lib/hooks.test.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
2
  import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "fs";
3
3
  import { join } from "path";
4
- import { tmpdir } from "os";
4
+ import { tmpdir, homedir } from "os";
5
+ import { realpathSync } from "fs";
5
6
  import {
6
7
  readSettings,
7
8
  writeSettings,
@@ -12,10 +13,19 @@ import {
12
13
  readStoredSalt,
13
14
  } from "./hooks.js";
14
15
 
16
+ const HOOK_COMMAND = "npx buddy-reroll --apply-hook";
17
+
18
+ function findOurHook(settings) {
19
+ const entries = settings.hooks?.SessionStart ?? [];
20
+ return entries.find(
21
+ (e) => typeof e === "object" && e.hooks?.some((h) => h.command === HOOK_COMMAND)
22
+ );
23
+ }
24
+
15
25
  let tempDir;
16
26
 
17
27
  beforeEach(() => {
18
- tempDir = mkdtempSync(join(tmpdir(), "buddy-reroll-test-"));
28
+ tempDir = mkdtempSync(join(realpathSync(tmpdir()), "buddy-reroll-test-"));
19
29
  process.env.CLAUDE_CONFIG_DIR = tempDir;
20
30
  });
21
31
 
@@ -26,215 +36,203 @@ afterEach(() => {
26
36
 
27
37
  describe("readSettings", () => {
28
38
  it("returns empty object when file doesn't exist", () => {
29
- const settingsPath = join(tempDir, "settings.json");
30
- expect(readSettings(settingsPath)).toEqual({});
39
+ expect(readSettings(join(tempDir, "settings.json"))).toEqual({});
31
40
  });
32
41
 
33
42
  it("parses valid JSON file", () => {
34
- const settingsPath = join(tempDir, "settings.json");
35
- writeSettings(settingsPath, { permissions: { allow: [] } });
36
- expect(readSettings(settingsPath)).toEqual({ permissions: { allow: [] } });
43
+ const p = join(tempDir, "settings.json");
44
+ writeSettings(p, { permissions: { allow: [] } });
45
+ expect(readSettings(p)).toEqual({ permissions: { allow: [] } });
37
46
  });
38
47
 
39
- it("returns empty object for corrupted JSON", () => {
40
- const settingsPath = join(tempDir, "settings.json");
41
- writeFileSync(settingsPath, "{ invalid json");
42
- expect(readSettings(settingsPath)).toEqual({});
48
+ it("returns null for corrupted JSON", () => {
49
+ const p = join(tempDir, "settings.json");
50
+ writeFileSync(p, "{ invalid json");
51
+ expect(readSettings(p)).toBeNull();
43
52
  });
44
53
  });
45
54
 
46
55
  describe("writeSettings", () => {
47
56
  it("creates parent directories if needed", () => {
48
- const settingsPath = join(tempDir, "nested", "dir", "settings.json");
49
- writeSettings(settingsPath, { test: true });
50
- expect(readSettings(settingsPath)).toEqual({ test: true });
57
+ const p = join(tempDir, "nested", "dir", "settings.json");
58
+ writeSettings(p, { test: true });
59
+ expect(readSettings(p)).toEqual({ test: true });
51
60
  });
52
61
 
53
- it("writes JSON with 2-space indent and newline", () => {
54
- const settingsPath = join(tempDir, "settings.json");
55
- writeSettings(settingsPath, { a: 1, b: 2 });
56
- const content = readFileSync(settingsPath, "utf-8");
62
+ it("writes JSON with 2-space indent and trailing newline", () => {
63
+ const p = join(tempDir, "settings.json");
64
+ writeSettings(p, { a: 1 });
65
+ const content = readFileSync(p, "utf-8");
57
66
  expect(content).toContain(" ");
58
67
  expect(content.endsWith("\n")).toBe(true);
59
68
  });
60
69
  });
61
70
 
62
71
  describe("installHook", () => {
63
- it("creates settings.json if missing", () => {
64
- const settingsPath = join(tempDir, "settings.json");
65
- const result = installHook(settingsPath);
72
+ it("creates settings.json with correct hook format", () => {
73
+ const p = join(tempDir, "settings.json");
74
+ const result = installHook(p);
66
75
  expect(result.installed).toBe(true);
67
- const settings = readSettings(settingsPath);
68
- expect(settings.hooks.SessionStart).toContain("npx buddy-reroll --apply-hook");
69
- });
70
-
71
- it("adds hook to existing settings", () => {
72
- const settingsPath = join(tempDir, "settings.json");
73
- writeSettings(settingsPath, { permissions: { allow: [] } });
74
- installHook(settingsPath);
75
- const settings = readSettings(settingsPath);
76
- expect(settings.permissions).toEqual({ allow: [] });
77
- expect(settings.hooks.SessionStart).toContain("npx buddy-reroll --apply-hook");
78
- });
79
-
80
- it("is idempotent - doesn't duplicate hook", () => {
81
- const settingsPath = join(tempDir, "settings.json");
82
- installHook(settingsPath);
83
- const result = installHook(settingsPath);
76
+ const settings = readSettings(p);
77
+ const hook = findOurHook(settings);
78
+ expect(hook).toBeDefined();
79
+ expect(hook.matcher).toBeUndefined();
80
+ expect(hook.hooks[0].type).toBe("command");
81
+ expect(hook.hooks[0].command).toBe(HOOK_COMMAND);
82
+ });
83
+
84
+ it("adds hook to existing settings without clobbering", () => {
85
+ const p = join(tempDir, "settings.json");
86
+ writeSettings(p, { permissions: { allow: ["test"] } });
87
+ installHook(p);
88
+ const settings = readSettings(p);
89
+ expect(settings.permissions).toEqual({ allow: ["test"] });
90
+ expect(findOurHook(settings)).toBeDefined();
91
+ });
92
+
93
+ it("is idempotent", () => {
94
+ const p = join(tempDir, "settings.json");
95
+ installHook(p);
96
+ const result = installHook(p);
84
97
  expect(result.installed).toBe(false);
85
98
  expect(result.reason).toBe("already installed");
86
- const settings = readSettings(settingsPath);
87
- const count = settings.hooks.SessionStart.filter(cmd => cmd === "npx buddy-reroll --apply-hook").length;
88
- expect(count).toBe(1);
89
- });
90
-
91
- it("preserves existing hooks in SessionStart array", () => {
92
- const settingsPath = join(tempDir, "settings.json");
93
- writeSettings(settingsPath, {
94
- hooks: { SessionStart: ["existing-hook"] },
95
- });
96
- installHook(settingsPath);
97
- const settings = readSettings(settingsPath);
98
- expect(settings.hooks.SessionStart).toContain("existing-hook");
99
- expect(settings.hooks.SessionStart).toContain("npx buddy-reroll --apply-hook");
99
+ const settings = readSettings(p);
100
+ const hooks = settings.hooks.SessionStart.filter(
101
+ (e) => typeof e === "object" && e.hooks?.some((h) => h.command === HOOK_COMMAND)
102
+ );
103
+ expect(hooks.length).toBe(1);
104
+ });
105
+
106
+ it("migrates old string-format hook to new format", () => {
107
+ const p = join(tempDir, "settings.json");
108
+ writeSettings(p, { hooks: { SessionStart: [HOOK_COMMAND] } });
109
+ installHook(p);
110
+ const settings = readSettings(p);
111
+ const strings = settings.hooks.SessionStart.filter((e) => typeof e === "string");
112
+ expect(strings.length).toBe(0);
113
+ expect(findOurHook(settings)).toBeDefined();
114
+ });
115
+
116
+ it("preserves other hooks in SessionStart array", () => {
117
+ const p = join(tempDir, "settings.json");
118
+ const otherHook = { matcher: "Bash", hooks: [{ type: "command", command: "echo hi" }] };
119
+ writeSettings(p, { hooks: { SessionStart: [otherHook] } });
120
+ installHook(p);
121
+ const settings = readSettings(p);
122
+ expect(settings.hooks.SessionStart.length).toBe(2);
123
+ expect(settings.hooks.SessionStart[0]).toEqual(otherHook);
100
124
  });
101
125
  });
102
126
 
103
127
  describe("removeHook", () => {
104
- it("removes hook from SessionStart", () => {
105
- const settingsPath = join(tempDir, "settings.json");
106
- installHook(settingsPath);
107
- const result = removeHook(settingsPath);
128
+ it("removes hook", () => {
129
+ const p = join(tempDir, "settings.json");
130
+ installHook(p);
131
+ const result = removeHook(p);
108
132
  expect(result.removed).toBe(true);
109
- const settings = readSettings(settingsPath);
110
- expect(settings.hooks?.SessionStart?.includes("npx buddy-reroll --apply-hook") ?? false).toBe(false);
133
+ expect(findOurHook(readSettings(p))).toBeUndefined();
111
134
  });
112
135
 
113
- it("deletes empty SessionStart array", () => {
114
- const settingsPath = join(tempDir, "settings.json");
115
- installHook(settingsPath);
116
- removeHook(settingsPath);
117
- const settings = readSettings(settingsPath);
118
- expect(settings.hooks?.SessionStart).toBeUndefined();
136
+ it("removes old string-format hook too", () => {
137
+ const p = join(tempDir, "settings.json");
138
+ writeSettings(p, { hooks: { SessionStart: [HOOK_COMMAND] } });
139
+ const result = removeHook(p);
140
+ expect(result.removed).toBe(true);
119
141
  });
120
142
 
121
- it("deletes empty hooks object", () => {
122
- const settingsPath = join(tempDir, "settings.json");
123
- installHook(settingsPath);
124
- removeHook(settingsPath);
125
- const settings = readSettings(settingsPath);
143
+ it("deletes empty SessionStart and hooks", () => {
144
+ const p = join(tempDir, "settings.json");
145
+ installHook(p);
146
+ removeHook(p);
147
+ const settings = readSettings(p);
126
148
  expect(settings.hooks).toBeUndefined();
127
149
  });
128
150
 
129
- it("returns false when hook not installed", () => {
130
- const settingsPath = join(tempDir, "settings.json");
131
- const result = removeHook(settingsPath);
132
- expect(result.removed).toBe(false);
133
- expect(result.reason).toBe("not installed");
151
+ it("returns false when not installed", () => {
152
+ const p = join(tempDir, "settings.json");
153
+ expect(removeHook(p).removed).toBe(false);
134
154
  });
135
155
 
136
- it("preserves other hooks in SessionStart", () => {
137
- const settingsPath = join(tempDir, "settings.json");
138
- writeSettings(settingsPath, {
139
- hooks: { SessionStart: ["other-hook", "npx buddy-reroll --apply-hook"] },
140
- });
141
- removeHook(settingsPath);
142
- const settings = readSettings(settingsPath);
143
- expect(settings.hooks.SessionStart).toContain("other-hook");
144
- expect(settings.hooks.SessionStart).not.toContain("npx buddy-reroll --apply-hook");
156
+ it("preserves other hooks", () => {
157
+ const p = join(tempDir, "settings.json");
158
+ const otherHook = { matcher: "Bash", hooks: [{ type: "command", command: "echo hi" }] };
159
+ writeSettings(p, { hooks: { SessionStart: [otherHook, HOOK_COMMAND] } });
160
+ removeHook(p);
161
+ const settings = readSettings(p);
162
+ expect(settings.hooks.SessionStart).toEqual([otherHook]);
145
163
  });
146
164
 
147
165
  it("preserves other hook types", () => {
148
- const settingsPath = join(tempDir, "settings.json");
149
- writeSettings(settingsPath, {
150
- hooks: {
151
- SessionStart: ["npx buddy-reroll --apply-hook"],
152
- OnExit: ["some-command"],
153
- },
154
- });
155
- removeHook(settingsPath);
156
- const settings = readSettings(settingsPath);
157
- expect(settings.hooks.OnExit).toContain("some-command");
166
+ const p = join(tempDir, "settings.json");
167
+ installHook(p);
168
+ const settings = readSettings(p);
169
+ settings.hooks.PostToolUse = [{ matcher: "Edit", hooks: [{ type: "command", command: "lint" }] }];
170
+ writeSettings(p, settings);
171
+ removeHook(p);
172
+ const after = readSettings(p);
173
+ expect(after.hooks.PostToolUse).toBeDefined();
158
174
  });
159
175
  });
160
176
 
161
177
  describe("isHookInstalled", () => {
162
- it("returns true after install", () => {
163
- const settingsPath = join(tempDir, "settings.json");
164
- installHook(settingsPath);
165
- expect(isHookInstalled(settingsPath)).toBe(true);
178
+ it("true after install", () => {
179
+ const p = join(tempDir, "settings.json");
180
+ installHook(p);
181
+ expect(isHookInstalled(p)).toBe(true);
166
182
  });
167
183
 
168
- it("returns false after remove", () => {
169
- const settingsPath = join(tempDir, "settings.json");
170
- installHook(settingsPath);
171
- removeHook(settingsPath);
172
- expect(isHookInstalled(settingsPath)).toBe(false);
184
+ it("false after remove", () => {
185
+ const p = join(tempDir, "settings.json");
186
+ installHook(p);
187
+ removeHook(p);
188
+ expect(isHookInstalled(p)).toBe(false);
173
189
  });
174
190
 
175
- it("returns false when settings don't exist", () => {
176
- const settingsPath = join(tempDir, "settings.json");
177
- expect(isHookInstalled(settingsPath)).toBe(false);
191
+ it("false when no file", () => {
192
+ expect(isHookInstalled(join(tempDir, "settings.json"))).toBe(false);
178
193
  });
179
194
 
180
- it("returns false when hooks don't exist", () => {
181
- const settingsPath = join(tempDir, "settings.json");
182
- writeSettings(settingsPath, { permissions: { allow: [] } });
183
- expect(isHookInstalled(settingsPath)).toBe(false);
195
+ it("detects old string-format as installed", () => {
196
+ const p = join(tempDir, "settings.json");
197
+ writeSettings(p, { hooks: { SessionStart: [HOOK_COMMAND] } });
198
+ expect(isHookInstalled(p)).toBe(true);
184
199
  });
185
200
  });
186
201
 
187
202
  describe("storeSalt and readStoredSalt", () => {
188
- it("roundtrips salt with timestamp", () => {
189
- const salt = "test-salt-value";
190
- storeSalt(salt);
203
+ it("roundtrips salt", () => {
204
+ storeSalt("test-salt");
191
205
  const stored = readStoredSalt();
192
- expect(stored.salt).toBe(salt);
193
- expect(typeof stored.timestamp).toBe("number");
206
+ expect(stored.salt).toBe("test-salt");
194
207
  expect(stored.timestamp).toBeGreaterThan(0);
195
208
  });
196
209
 
197
- it("returns null when file doesn't exist", () => {
210
+ it("returns null when missing", () => {
198
211
  expect(readStoredSalt()).toBeNull();
199
212
  });
200
213
 
201
- it("returns null for corrupted salt file", () => {
202
- const saltPath = join(tempDir, ".buddy-reroll.json");
203
- writeFileSync(saltPath, "{ invalid json");
214
+ it("returns null for corrupted file", () => {
215
+ writeFileSync(join(tempDir, ".buddy-reroll.json"), "broken");
204
216
  expect(readStoredSalt()).toBeNull();
205
217
  });
206
-
207
- it("creates parent directories for salt file", () => {
208
- const salt = "another-test-salt";
209
- storeSalt(salt);
210
- const stored = readStoredSalt();
211
- expect(stored.salt).toBe(salt);
212
- });
213
218
  });
214
219
 
215
220
  describe("integration", () => {
216
- it("preserves existing settings when installing hook", () => {
217
- const settingsPath = join(tempDir, "settings.json");
218
- writeSettings(settingsPath, {
221
+ it("preserves complex settings through install and remove", () => {
222
+ const p = join(tempDir, "settings.json");
223
+ writeSettings(p, {
219
224
  permissions: { allow: ["some-permission"] },
220
225
  other: { nested: { value: 42 } },
221
226
  });
222
- installHook(settingsPath);
223
- const settings = readSettings(settingsPath);
224
- expect(settings.permissions).toEqual({ allow: ["some-permission"] });
225
- expect(settings.other).toEqual({ nested: { value: 42 } });
226
- expect(settings.hooks.SessionStart).toContain("npx buddy-reroll --apply-hook");
227
- });
228
-
229
- it("preserves existing settings when removing hook", () => {
230
- const settingsPath = join(tempDir, "settings.json");
231
- writeSettings(settingsPath, {
232
- permissions: { allow: ["some-permission"] },
233
- hooks: { SessionStart: ["npx buddy-reroll --apply-hook"] },
234
- });
235
- removeHook(settingsPath);
236
- const settings = readSettings(settingsPath);
237
- expect(settings.permissions).toEqual({ allow: ["some-permission"] });
238
- expect(settings.hooks).toBeUndefined();
227
+ installHook(p);
228
+ const after = readSettings(p);
229
+ expect(after.permissions).toEqual({ allow: ["some-permission"] });
230
+ expect(after.other).toEqual({ nested: { value: 42 } });
231
+ expect(findOurHook(after)).toBeDefined();
232
+
233
+ removeHook(p);
234
+ const final = readSettings(p);
235
+ expect(final.permissions).toEqual({ allow: ["some-permission"] });
236
+ expect(final.hooks).toBeUndefined();
239
237
  });
240
238
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-reroll",
3
- "version": "0.3.2",
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