buddy-reroll 0.2.0 → 0.3.0

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/ui.jsx CHANGED
@@ -1,6 +1,8 @@
1
1
  import React, { useState, useEffect, useRef } from "react";
2
2
  import { render, Box, Text, useApp, useInput } from "ink";
3
- // Simple spinner avoids @inkjs/ui dependency for one component
3
+ import { renderSprite, RARITY_STARS, RARITY_COLORS } from "./sprites.js";
4
+ import { existsSync, copyFileSync } from "fs";
5
+
4
6
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5
7
  function Spinner({ label }) {
6
8
  const [frame, setFrame] = useState(0);
@@ -10,10 +12,8 @@ function Spinner({ label }) {
10
12
  }, []);
11
13
  return <Text><Text color="cyan">{SPINNER_FRAMES[frame]}</Text> {label}</Text>;
12
14
  }
13
- import { renderSprite, RARITY_STARS, RARITY_COLORS } from "./sprites.js";
14
- import { existsSync, copyFileSync } from "fs";
15
15
 
16
- // ── Shared Components ───────────────────────────────────────────────────
16
+ // ── Components ──────────────────────────────────────────────────────────
17
17
 
18
18
  function KeyHint({ children }) {
19
19
  return <Text italic dimColor>{children}</Text>;
@@ -86,8 +86,6 @@ function ConfirmSelect({ label, onConfirm, onCancel, onBack, isActive }) {
86
86
  );
87
87
  }
88
88
 
89
- // ── PreviewCard ─────────────────────────────────────────────────────────
90
-
91
89
  function PreviewCard({ species, rarity, eye, hat, shiny, stats }) {
92
90
  const color = RARITY_COLORS[rarity] ?? "white";
93
91
  const stars = RARITY_STARS[rarity] ?? "";
@@ -97,8 +95,8 @@ function PreviewCard({ species, rarity, eye, hat, shiny, stats }) {
97
95
  <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
98
96
  <Box>
99
97
  <Box flexDirection="column">
100
- {sprite.map((line, i) => (
101
- <Text key={i} color={color}>{line}</Text>
98
+ {sprite.map((line, lineIdx) => (
99
+ <Text key={`sprite-${lineIdx}-${line.trim()}`} color={color}>{line}</Text>
102
100
  ))}
103
101
  </Box>
104
102
  <Box flexDirection="column" marginLeft={2}>
@@ -127,8 +125,6 @@ function PreviewCard({ species, rarity, eye, hat, shiny, stats }) {
127
125
  );
128
126
  }
129
127
 
130
- // ── Step Components ─────────────────────────────────────────────────────
131
-
132
128
  function ShowCurrentStep({ isActive }) {
133
129
  const { exit } = useApp();
134
130
 
@@ -172,8 +168,8 @@ function SpeciesStep({ speciesList, current, onChange, onSubmit, onBack, isActiv
172
168
 
173
169
  function SearchStep({ userId, target, bruteForce, onFound, onFail, isActive }) {
174
170
  const [progress, setProgress] = useState("");
175
- const [error, setError] = useState(null);
176
171
  const cancelRef = useRef(false);
172
+ const hasStarted = useRef(false);
177
173
  const { exit } = useApp();
178
174
 
179
175
  useInput((input, key) => {
@@ -184,6 +180,8 @@ function SearchStep({ userId, target, bruteForce, onFound, onFail, isActive }) {
184
180
  }, { isActive });
185
181
 
186
182
  useEffect(() => {
183
+ if (hasStarted.current) return;
184
+ hasStarted.current = true;
187
185
  (async () => {
188
186
  const found = await bruteForce(userId, target, (checked, elapsed) => {
189
187
  if (!cancelRef.current) {
@@ -192,22 +190,13 @@ function SearchStep({ userId, target, bruteForce, onFound, onFail, isActive }) {
192
190
  });
193
191
  if (cancelRef.current) return;
194
192
  if (found) onFound(found);
195
- else setError("No matching salt found. Try relaxing constraints.");
193
+ else onFail();
196
194
  })();
197
- }, []);
198
-
199
- if (error) {
200
- return (
201
- <Box flexDirection="column">
202
- <Text color="red">✗ {error}</Text>
203
- <KeyHint>Press esc to exit</KeyHint>
204
- </Box>
205
- );
206
- }
195
+ }, [bruteForce, userId, target, onFound, onFail]);
207
196
 
208
197
  return (
209
198
  <Box flexDirection="column">
210
- <Spinner label={progress || "Searching for matching salt..."} />
199
+ <Spinner label={progress || "Looking for your buddy..."} />
211
200
  <KeyHint>esc to cancel</KeyHint>
212
201
  </Box>
213
202
  );
@@ -215,6 +204,7 @@ function SearchStep({ userId, target, bruteForce, onFound, onFail, isActive }) {
215
204
 
216
205
  function DoneStep({ messages, isActive }) {
217
206
  const { exit } = useApp();
207
+ const hasErrors = messages.some((msg) => msg.type === "error");
218
208
 
219
209
  useInput(() => {
220
210
  exit();
@@ -222,38 +212,41 @@ function DoneStep({ messages, isActive }) {
222
212
 
223
213
  return (
224
214
  <Box flexDirection="column">
225
- {messages.map((msg, i) => (
226
- <Text key={i} color="green">✓ {msg}</Text>
215
+ {messages.map((msg) => (
216
+ <Text key={`${msg.type}-${msg.text}`} color={msg.type === "error" ? "red" : "green"}>
217
+ {msg.type === "error" ? "✗ " : "✓ "}{msg.text}
218
+ </Text>
227
219
  ))}
228
220
  <Box marginTop={1}>
229
- <Text bold>Done! Restart Claude Code and run /buddy to hatch your new companion.</Text>
221
+ <Text bold>
222
+ {hasErrors
223
+ ? "Something went wrong — check the issue above and try again."
224
+ : "All set! Restart Claude Code and say /buddy to meet your new friend."}
225
+ </Text>
230
226
  </Box>
231
227
  <KeyHint>Press any key to exit</KeyHint>
232
228
  </Box>
233
229
  );
234
230
  }
235
231
 
236
- // ── Step Flow ───────────────────────────────────────────────────────────
237
-
238
- const STEP_ORDER = ["action", "species", "rarity", "eye", "hat", "shiny", "confirm"];
232
+ const STEP_ORDER = ["action", "species", "rarity", "eye", "hat", "shiny", "peak", "dump", "confirm"];
239
233
 
240
- function getPrevStep(current, rarity) {
234
+ function getPrevStep(current, rarity, peak) {
241
235
  const idx = STEP_ORDER.indexOf(current);
242
236
  if (idx <= 0) return null;
243
237
  let prev = STEP_ORDER[idx - 1];
244
- // Skip hat when going back if common
245
238
  if (prev === "hat" && rarity === "common") prev = "eye";
239
+ if (prev === "dump" && peak === null) prev = "shiny";
240
+ if (prev === "peak") prev = "shiny";
246
241
  return prev;
247
242
  }
248
243
 
249
- // ── Main App ────────────────────────────────────────────────────────────
250
-
251
244
  function App({ opts }) {
252
245
  const { exit } = useApp();
253
246
  const {
254
247
  currentRoll, currentSalt, binaryPath, configPath, userId,
255
- bruteForce, patchBinary, resignBinary, clearCompanion, isClaudeRunning,
256
- rollFrom, matches, SPECIES, RARITIES, RARITY_LABELS, EYES, HATS,
248
+ bruteForce, patchBinary, resignBinary, clearCompanion, getPatchability, isClaudeRunning,
249
+ rollFrom, matches, SPECIES, RARITIES, RARITY_LABELS, EYES, HATS, STAT_NAMES,
257
250
  } = opts;
258
251
 
259
252
  const [step, setStep] = useState("action");
@@ -262,14 +255,23 @@ function App({ opts }) {
262
255
  const [eye, setEye] = useState(currentRoll.eye);
263
256
  const [hat, setHat] = useState(currentRoll.hat);
264
257
  const [shiny, setShiny] = useState(currentRoll.shiny);
258
+ const [peak, setPeak] = useState(null);
259
+ const [dump, setDump] = useState(null);
265
260
  const [found, setFound] = useState(null);
266
261
  const [doneMessages, setDoneMessages] = useState([]);
267
262
 
268
263
  const showStats = step === "showCurrent" || step === "result" || step === "done";
269
264
  const displayRoll = found ? found.result : { species, rarity, eye, hat, shiny, stats: currentRoll.stats };
265
+ const effectiveHat = rarity === "common" ? "none" : hat;
266
+ const buildTarget = (s = shiny) => {
267
+ const t = { species, rarity, eye, hat: effectiveHat, shiny: s };
268
+ if (peak) t.peak = peak;
269
+ if (dump) t.dump = dump;
270
+ return t;
271
+ };
270
272
 
271
273
  const goBack = (toStep) => {
272
- const prev = toStep || getPrevStep(step, rarity);
274
+ const prev = toStep || getPrevStep(step, rarity, peak);
273
275
  if (prev) setStep(prev);
274
276
  else exit();
275
277
  };
@@ -301,16 +303,29 @@ function App({ opts }) {
301
303
  if (action === "current") {
302
304
  setStep("showCurrent");
303
305
  } else if (action === "restore") {
304
- const backupPath = binaryPath + ".backup";
306
+ const patchability = getPatchability(binaryPath);
307
+ if (!patchability.ok) {
308
+ setDoneMessages([{ type: "error", text: patchability.message }]);
309
+ setStep("done");
310
+ return;
311
+ }
312
+
313
+ const { backupPath } = patchability;
305
314
  if (!existsSync(backupPath)) {
306
- setDoneMessages(["No backup found. Nothing to restore."]);
315
+ setDoneMessages([{ type: "info", text: "No backup found nothing to undo." }]);
307
316
  setStep("done");
308
317
  return;
309
318
  }
310
- copyFileSync(backupPath, binaryPath);
311
- resignBinary(binaryPath);
312
- clearCompanion(configPath);
313
- setDoneMessages(["Restored! Restart Claude Code and run /buddy."]);
319
+
320
+ try {
321
+ copyFileSync(backupPath, binaryPath);
322
+ resignBinary(binaryPath);
323
+ clearCompanion(configPath);
324
+ setDoneMessages([{ type: "success", text: "Restored! Restart Claude Code and say /buddy to see your original friend." }]);
325
+ } catch (err) {
326
+ setDoneMessages([{ type: "error", text: err.message }]);
327
+ }
328
+
314
329
  setStep("done");
315
330
  } else {
316
331
  setStep("species");
@@ -379,36 +394,80 @@ function App({ opts }) {
379
394
  isActive={step === "shiny"}
380
395
  onConfirm={() => {
381
396
  setShiny(true);
382
- const target = { species, rarity, eye, hat: rarity === "common" ? "none" : hat, shiny: true };
383
- if (matches(currentRoll, target)) {
384
- setDoneMessages(["Already matching! No changes needed."]);
397
+ if (matches(currentRoll, buildTarget(true))) {
398
+ setDoneMessages([{ type: "success", text: "Your buddy already looks like that!" }]);
385
399
  setStep("done");
386
400
  } else {
387
- setStep("confirm");
401
+ setStep("peak");
388
402
  }
389
403
  }}
390
404
  onCancel={() => {
391
405
  setShiny(false);
392
- const target = { species, rarity, eye, hat: rarity === "common" ? "none" : hat, shiny: false };
393
- if (matches(currentRoll, target)) {
394
- setDoneMessages(["Already matching! No changes needed."]);
406
+ if (matches(currentRoll, buildTarget(false))) {
407
+ setDoneMessages([{ type: "success", text: "Your buddy already looks like that!" }]);
395
408
  setStep("done");
396
409
  } else {
410
+ setStep("peak");
411
+ }
412
+ }}
413
+ onBack={() => goBack()}
414
+ />
415
+ )}
416
+
417
+ {step === "peak" && (
418
+ <ListSelect
419
+ label="Peak stat (highest)"
420
+ options={[
421
+ { label: "Any (random)", value: "any" },
422
+ ...(STAT_NAMES || []).map(s => ({ label: s, value: s })),
423
+ ]}
424
+ defaultValue="any"
425
+ onSubmit={(v) => {
426
+ setPeak(v === "any" ? null : v);
427
+ if (v === "any") {
397
428
  setStep("confirm");
429
+ } else {
430
+ setStep("dump");
398
431
  }
399
432
  }}
400
433
  onBack={() => goBack()}
434
+ isActive={step === "peak"}
435
+ />
436
+ )}
437
+
438
+ {step === "dump" && (
439
+ <ListSelect
440
+ label="Dump stat (lowest)"
441
+ options={[
442
+ { label: "Any (random)", value: "any" },
443
+ ...(STAT_NAMES || []).filter(s => s !== peak).map(s => ({ label: s, value: s })),
444
+ ]}
445
+ defaultValue="any"
446
+ onSubmit={(v) => {
447
+ setDump(v === "any" ? null : v);
448
+ setStep("confirm");
449
+ }}
450
+ onBack={() => goBack()}
451
+ isActive={step === "dump"}
401
452
  />
402
453
  )}
403
454
 
404
455
  {step === "confirm" && (
405
456
  <Box flexDirection="column">
406
- <Text>Target: <Text bold>{species}</Text> / <Text bold>{rarity}</Text> / eye:{eye} / hat:{rarity === "common" ? "none" : hat}{shiny ? " / shiny" : ""}</Text>
457
+ <Text>Target: <Text bold>{species}</Text> / <Text bold>{rarity}</Text> / eye:{eye} / hat:{effectiveHat}{shiny ? " / shiny" : ""}{peak ? ` / peak:${peak}` : ""}{dump ? ` / dump:${dump}` : ""}</Text>
407
458
  {isClaudeRunning() && <Text color="yellow">⚠ Claude Code appears to be running. Quit it before patching.</Text>}
408
459
  <ConfirmSelect
409
460
  label="Search and apply?"
410
461
  isActive={step === "confirm"}
411
- onConfirm={() => setStep("search")}
462
+ onConfirm={() => {
463
+ const patchability = getPatchability(binaryPath);
464
+ if (!patchability.ok) {
465
+ setDoneMessages([{ type: "error", text: patchability.message }]);
466
+ setStep("done");
467
+ return;
468
+ }
469
+ setStep("search");
470
+ }}
412
471
  onCancel={() => exit()}
413
472
  onBack={() => goBack()}
414
473
  />
@@ -418,11 +477,11 @@ function App({ opts }) {
418
477
  {step === "search" && (
419
478
  <SearchStep
420
479
  userId={userId}
421
- target={{ species, rarity, eye, hat: rarity === "common" ? "none" : hat, shiny }}
480
+ target={buildTarget()}
422
481
  bruteForce={bruteForce}
423
482
  onFound={(f) => { setFound(f); setStep("result"); }}
424
483
  onFail={() => {
425
- setDoneMessages(["No matching salt found. Try relaxing constraints."]);
484
+ setDoneMessages([{ type: "error", text: "Couldn't find a match. Try being less picky!" }]);
426
485
  setStep("done");
427
486
  }}
428
487
  isActive={step === "search"}
@@ -436,17 +495,30 @@ function App({ opts }) {
436
495
  label="Apply patch?"
437
496
  isActive={step === "result"}
438
497
  onConfirm={() => {
498
+ const patchability = getPatchability(binaryPath);
499
+ if (!patchability.ok) {
500
+ setDoneMessages([{ type: "error", text: patchability.message }]);
501
+ setStep("done");
502
+ return;
503
+ }
504
+
439
505
  const msgs = [];
440
- const backupPath = binaryPath + ".backup";
441
- if (!existsSync(backupPath)) {
442
- copyFileSync(binaryPath, backupPath);
443
- msgs.push(`Backup saved to ${backupPath}`);
506
+ const { backupPath } = patchability;
507
+
508
+ try {
509
+ if (!existsSync(backupPath)) {
510
+ copyFileSync(binaryPath, backupPath);
511
+ msgs.push({ type: "success", text: `Saved a backup just in case` });
512
+ }
513
+ const count = patchBinary(binaryPath, currentSalt, found.salt);
514
+ msgs.push({ type: "success", text: "Applied!" });
515
+ if (resignBinary(binaryPath)) msgs.push({ type: "success", text: "Re-signed for macOS" });
516
+ clearCompanion(configPath);
517
+ msgs.push({ type: "success", text: "Cleaned up old buddy data" });
518
+ } catch (err) {
519
+ msgs.push({ type: "error", text: err.message });
444
520
  }
445
- const count = patchBinary(binaryPath, currentSalt, found.salt);
446
- msgs.push(`Patched ${count} occurrence(s)`);
447
- if (resignBinary(binaryPath)) msgs.push("Binary re-signed (ad-hoc codesign)");
448
- clearCompanion(configPath);
449
- msgs.push("Companion data cleared");
521
+
450
522
  setDoneMessages(msgs);
451
523
  setStep("done");
452
524
  }}
@@ -461,8 +533,6 @@ function App({ opts }) {
461
533
  );
462
534
  }
463
535
 
464
- // ── Export ───────────────────────────────────────────────────────────────
465
-
466
536
  export async function runInteractiveUI(opts) {
467
537
  const { waitUntilExit } = render(<App opts={opts} />);
468
538
  await waitUntilExit();