@mauricode/token-derby 2.13.1 → 3.1.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/dist/bin.js CHANGED
@@ -36,7 +36,9 @@ var CLI_VERSION_HEADER = "x-cli-version";
36
36
  var USER_ID_HEADER = "x-user-id";
37
37
  var USER_TOKEN_HEADER = "x-user-token";
38
38
  var USER_NAME_MAX_LENGTH = 40;
39
+ var SECRET_TOKEN_BYTES = 32;
39
40
  var ORG_NAME_PATTERN = /^[A-Za-z0-9]{1,12}$/;
41
+ var DEVICE_LABEL_MAX_LENGTH = 40;
40
42
 
41
43
  // ../shared/dist/version-match.js
42
44
  var SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
@@ -197,9 +199,31 @@ var HATS = [
197
199
  // Claim-only: awarded by an admin, never from a roll. Only the A channel cycles,
198
200
  // so the inset motif shifts colour while the cap body stays a static rust brown.
199
201
  // The body colour is kept out of the frame list so the motif never merges into it.
200
- { id: "contributor_cap", name: "Contributor Cap", rarity: "legendary", width: 11, anchor_x: 23, rows: [".....Q.....", "....QQQ....", "...QQQQQ...", "..QAAAQQQ..", "..QQAQQQQ..", ".QQQAQAAQQ.", ".QQQAQAQAQ.", ".QQQQQAQAQ.", ".QQQQQAAQQ.", ".QQQQQQQQQ."], colors: { A: "#21138b", Q: "#6f2801" }, animation: { type: "cycle", frames: ["#FF8C42", "#E0457B", "#8B5CF6", "#3B82F6", "#22D3EE", "#34D399", "#A3E635", "#FBBF24"], fps: 6 }, rollable: false }
202
+ { id: "contributor_cap", name: "Contributor Cap", rarity: "legendary", width: 11, anchor_x: 23, rows: [".....Q.....", "....QQQ....", "...QQQQQ...", "..QAAAQQQ..", "..QQAQQQQ..", ".QQQAQAAQQ.", ".QQQAQAQAQ.", ".QQQQQAQAQ.", ".QQQQQAAQQ.", ".QQQQQQQQQ."], colors: { A: "#21138b", Q: "#6f2801" }, animation: { type: "cycle", frames: ["#FF8C42", "#E0457B", "#8B5CF6", "#3B82F6", "#22D3EE", "#34D399", "#A3E635", "#FBBF24"], fps: 6 }, rollable: false },
203
+ // ── LIMITED (6) ────────────────────────────────────────────────────────
204
+ // London series. Claim-only by construction: limited is absent from
205
+ // TIER_WEIGHTS, so no roll can reach this tier.
206
+ { id: "bearskin", name: "Bearskin", rarity: "limited", width: 11, anchor_x: 23, rows: ["...CCCCC.Q.", "..CAAAAACQ.", "..AAAAAACQ.", "..AAAAAAAQ.", "..AAAAAAA..", "..AAAAAAA..", "..AAAAAAA..", "..AAAAAAA..", "..DDDDDDD..", "..AAAAAAA.."], variants: [{ A: "#23232A", Q: "#C8102E", C: "#15151A", D: "#FFD300" }], rollable: false },
207
+ { id: "roundel_cap", name: "Roundel Cap", rarity: "limited", width: 11, anchor_x: 23, rows: ["...........", "..AAAAAA...", ".AAAAAAAA..", ".AAQQQQQAA.", ".AAQDDDQAA.", ".AFFFFFFFA.", ".AAQDDDQAA.", ".AAQQQQQAA.", ".AAAAAAAAA.", ".FFFFFFFFFF"], variants: [{ A: "#0B3B8C", Q: "#E32017", C: "#1050B8", D: "#F4F1E8", F: "#072A66" }], rollable: false },
208
+ { id: "union_topper", name: "Union Topper", rarity: "limited", width: 11, anchor_x: 23, rows: ["..CCCCCCC..", "..AQAAAQA..", "..AAQDQAA..", "..QQQDQQQ..", "..DDDDDDD..", "..QQQDQQQ..", "..AAQDQAA..", "..AQAAAQA..", ".CCCCCCCCC.", ".FFFFFFFFF."], variants: [{ A: "#0B2C6B", Q: "#F2F6FA", C: "#081F4D", D: "#C8102E", F: "#061838" }], rollable: false },
209
+ { id: "crown_jewels", name: "Crown Jewels", rarity: "limited", width: 11, anchor_x: 23, rows: ["...........", "....QQQ....", ".....Q.....", "..QQ.Q.QQ..", ".Q..QQQ..Q.", ".QCCCQCCCQ.", ".QCCCQCCCQ.", ".QQCCQCCQQ.", "..QQQQQQQ..", "..AAAAAAA.."], variants: [{ A: "#f5f5f5", Q: "#ffd700", C: "#5d0a8a" }], rollable: false },
210
+ { id: "black_cab", name: "Black Cab", rarity: "limited", width: 11, anchor_x: 22, rows: ["...........", "....CCC....", "..AAAAAA...", "..AQQAQA...", ".AAQQAQAAA.", ".AAAAAAAAAF", "AAAAAAAAAAF", "ADDAAAADDA.", ".DD....DD..", "..........."], variants: [{ A: "#141416", Q: "#8FB8D8", C: "#F5C518", D: "#3A3A40", F: "#D9DDE3" }], rollable: false },
211
+ { id: "london_bus", name: "London Bus", rarity: "limited", width: 11, anchor_x: 22, rows: ["...........", "...........", ".AAAAAAAAAA", "AAAAAAAAAAA", "ACCCACCCAAC", "AAAAAAAAACC", "ACCCACCCACC", "AAAAAAAAAAA", "AQQAAAAAQQA", ".QQ.....QQ."], variants: [{ A: "#e32402", Q: "#0d0c0c", C: "#dcaf09" }], rollable: false }
201
212
  ];
202
213
 
214
+ // ../shared/dist/hat-shape.js
215
+ function isAnimatedHat(hat) {
216
+ return "animation" in hat;
217
+ }
218
+ function hatColors(hat, variantIdx = 0) {
219
+ if (isAnimatedHat(hat))
220
+ return hat.colors;
221
+ return hat.variants[variantIdx] ?? hat.variants[0];
222
+ }
223
+ function channelColor(colors, ch) {
224
+ return colors[ch] ?? colors.A;
225
+ }
226
+
203
227
  // ../shared/dist/series-transform.js
204
228
  var PACE_WINDOW_MS = 15 * 6e4;
205
229
 
@@ -289,6 +313,22 @@ var STAMINA_PARAM_BOUNDS = {
289
313
  tired_multiplier: { min: 0.2, max: 0.9, step: 0.05, default: STAMINA.TIRED_MULTIPLIER }
290
314
  };
291
315
 
316
+ // ../shared/dist/devices.js
317
+ var DEVICE_CODE_LENGTH = Math.ceil(SECRET_TOKEN_BYTES * 4 / 3);
318
+ var UNSAFE_LABEL_CHARS = /[\p{Cc}\p{Cf}]/u;
319
+ function validateDeviceLabel(raw) {
320
+ if (typeof raw !== "string")
321
+ return { ok: false, message: "label is required" };
322
+ const label = raw.trim();
323
+ if (label.length < 1 || label.length > DEVICE_LABEL_MAX_LENGTH) {
324
+ return { ok: false, message: `label must be 1\u2013${DEVICE_LABEL_MAX_LENGTH} characters` };
325
+ }
326
+ if (UNSAFE_LABEL_CHARS.test(label)) {
327
+ return { ok: false, message: "label may not contain control or invisible characters" };
328
+ }
329
+ return { ok: true, label };
330
+ }
331
+
292
332
  // src/ui/HorseSprite.tsx
293
333
  import { Box, Text } from "ink";
294
334
 
@@ -415,7 +455,7 @@ function composeHatGrid(baseSprite, hat, variantIdx, horseColors) {
415
455
  grid[y + ext][x + horseOffsetX] = tagToColor(tag, horseColors);
416
456
  }
417
457
  }
418
- const hatColors = hatColorsFor(hat, variantIdx);
458
+ const colors = hatColors(hat, variantIdx);
419
459
  for (let y = 0; y < hatH; y++) {
420
460
  const row = hat.rows[y];
421
461
  for (let x = 0; x < hatW; x++) {
@@ -423,7 +463,7 @@ function composeHatGrid(baseSprite, hat, variantIdx, horseColors) {
423
463
  if (ch === "." || ch === void 0) continue;
424
464
  const gx = hat.anchor_x + x + horseOffsetX;
425
465
  if (gx < 0 || gx >= canvasW) continue;
426
- const color = ch === "A" ? hatColors.A : hatColors.Q ?? hatColors.A;
466
+ const color = channelColor(colors, ch);
427
467
  grid[y][gx] = color;
428
468
  }
429
469
  }
@@ -445,10 +485,6 @@ function tagToColor(tag, c) {
445
485
  return FIXED_COLORS.H;
446
486
  }
447
487
  }
448
- function hatColorsFor(hat, variantIdx) {
449
- if (hat.rarity === "legendary") return hat.colors;
450
- return hat.variants[variantIdx] ?? hat.variants[0];
451
- }
452
488
 
453
489
  // src/ui/half-blocks.ts
454
490
  var RESET = "\x1B[0m";
@@ -520,19 +556,19 @@ import { useEffect, useState } from "react";
520
556
  import { Box as Box2, Text as Text2 } from "ink";
521
557
  import { jsx as jsx2 } from "react/jsx-runtime";
522
558
  function AnimatedHorseSprite({ sprite, colors, hat }) {
523
- const isLegendary = hat.rarity === "legendary";
524
- const frames = isLegendary ? hat.animation.frames : [];
525
- const fps = isLegendary ? hat.animation.fps : 1;
559
+ const animated = isAnimatedHat(hat);
560
+ const frames = animated ? hat.animation.frames : [];
561
+ const fps = animated ? hat.animation.fps : 1;
526
562
  const [idx, setIdx] = useState(0);
527
563
  useEffect(() => {
528
- if (!isLegendary || frames.length <= 1) return;
564
+ if (!animated || frames.length <= 1) return;
529
565
  const interval = setInterval(
530
566
  () => setIdx((i) => (i + 1) % frames.length),
531
567
  Math.max(1, Math.round(1e3 / fps))
532
568
  );
533
569
  return () => clearInterval(interval);
534
- }, [isLegendary, frames.length, fps]);
535
- const renderedHat = isLegendary && frames[idx] ? { ...hat, colors: { ...hat.colors, A: frames[idx] } } : hat;
570
+ }, [animated, frames.length, fps]);
571
+ const renderedHat = animated && frames[idx] ? { ...hat, colors: { ...hat.colors, A: frames[idx] } } : hat;
536
572
  const { grid } = composeHatGrid(sprite, renderedHat, 0, colors);
537
573
  const lines = hexGridToHalfBlocks(grid);
538
574
  return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: lines.map((line, i) => /* @__PURE__ */ jsx2(Text2, { children: line }, i)) });
@@ -721,7 +757,7 @@ function HorseCreator({
721
757
  "]"
722
758
  ] })
723
759
  ] }),
724
- /* @__PURE__ */ jsx3(Box3, { marginBottom: 1, children: previewHatDef ? previewHatDef.rarity === "legendary" ? /* @__PURE__ */ jsx3(AnimatedHorseSprite, { sprite: MAIN_SPRITE, colors, hat: previewHatDef }) : /* @__PURE__ */ jsx3(HorseSprite, { sprite: MAIN_SPRITE, colors, hat: { hat: previewHatDef, variant: previewHat?.variant ?? 0 } }) : /* @__PURE__ */ jsx3(HorseSprite, { sprite: MAIN_SPRITE, colors }) }),
760
+ /* @__PURE__ */ jsx3(Box3, { marginBottom: 1, children: previewHatDef ? isAnimatedHat(previewHatDef) ? /* @__PURE__ */ jsx3(AnimatedHorseSprite, { sprite: MAIN_SPRITE, colors, hat: previewHatDef }) : /* @__PURE__ */ jsx3(HorseSprite, { sprite: MAIN_SPRITE, colors, hat: { hat: previewHatDef, variant: previewHat?.variant ?? 0 } }) : /* @__PURE__ */ jsx3(HorseSprite, { sprite: MAIN_SPRITE, colors }) }),
725
761
  /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", children: rows.map((r, i) => {
726
762
  const cursor = i === rowIdx ? "\u25BA" : " ";
727
763
  if (r.kind === "color") {
@@ -767,8 +803,8 @@ function renderHatLabel(hats, hatChoice, initialEquipped) {
767
803
  if (!collected) return /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "?" });
768
804
  const hat = hatById(collected.id);
769
805
  const name = hat?.name ?? collected.id;
770
- const variantSuffix = hat && hat.rarity !== "legendary" && collected.variant !== void 0 ? ` #${collected.variant + 1}` : "";
771
- const rarityColor = hat ? hat.rarity === "legendary" ? "yellow" : hat.rarity === "epic" ? "magenta" : hat.rarity === "rare" ? "blue" : "gray" : "gray";
806
+ const variantSuffix = hat && !isAnimatedHat(hat) && collected.variant !== void 0 ? ` #${collected.variant + 1}` : "";
807
+ const rarityColor = hat ? hat.rarity === "limited" ? "magentaBright" : hat.rarity === "legendary" ? "yellow" : hat.rarity === "epic" ? "magenta" : hat.rarity === "rare" ? "blue" : "gray" : "gray";
772
808
  const equippedMark = initialEquipped === hatChoice ? " \u2713" : "";
773
809
  return /* @__PURE__ */ jsxs(Fragment, { children: [
774
810
  name,
@@ -825,8 +861,8 @@ var HEARTBEAT_RETRY_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
825
861
  // src/version.ts
826
862
  import { createRequire } from "module";
827
863
  function readVersion() {
828
- if ("2.13.1".length > 0) {
829
- return "2.13.1";
864
+ if ("3.1.0".length > 0) {
865
+ return "3.1.0";
830
866
  }
831
867
  try {
832
868
  const req = createRequire(import.meta.url);
@@ -871,18 +907,37 @@ function geminiTmpDir() {
871
907
  }
872
908
 
873
909
  // src/identity/identity.ts
874
- async function loadIdentity() {
910
+ async function readIdentityFile() {
911
+ let raw;
875
912
  try {
876
- const raw = await fs.readFile(identityFile(), "utf8");
877
- const parsed = JSON.parse(raw);
878
- if (typeof parsed.user_id === "string" && typeof parsed.display_name === "string" && typeof parsed.secret_token === "string" && typeof parsed.created_at === "string") {
879
- return parsed;
880
- }
881
- return null;
913
+ raw = await fs.readFile(identityFile(), "utf8");
882
914
  } catch (e) {
883
- if (e?.code === "ENOENT") return null;
884
- return null;
915
+ if (e?.code === "ENOENT") return { kind: "missing" };
916
+ return { kind: "unreadable", reason: `could not be read (${e?.code ?? "read error"})` };
917
+ }
918
+ let parsed;
919
+ try {
920
+ parsed = JSON.parse(raw);
921
+ } catch {
922
+ return { kind: "unreadable", reason: "is not valid JSON" };
885
923
  }
924
+ if (!isPlainObject(parsed)) {
925
+ return { kind: "unreadable", reason: "does not contain an identity object" };
926
+ }
927
+ if (typeof parsed.user_id === "string" && typeof parsed.display_name === "string" && typeof parsed.secret_token === "string" && typeof parsed.created_at === "string") {
928
+ return { kind: "ok", identity: parsed };
929
+ }
930
+ if (!("secret_token" in parsed)) {
931
+ return { kind: "no-credential", reason: "holds no credential this version can use" };
932
+ }
933
+ return { kind: "unreadable", reason: "is missing fields this version expects" };
934
+ }
935
+ function isPlainObject(value) {
936
+ return typeof value === "object" && value !== null && !Array.isArray(value);
937
+ }
938
+ async function loadIdentity() {
939
+ const state = await readIdentityFile();
940
+ return state.kind === "ok" ? state.identity : null;
886
941
  }
887
942
  async function saveIdentity(identity) {
888
943
  await fs.mkdir(homeDir(), { recursive: true });
@@ -923,12 +978,12 @@ function getIdentity() {
923
978
  function _resetIdentityCacheForTests() {
924
979
  identityCache = null;
925
980
  }
926
- async function request(method, path9, body, horseAuthToken, fetchImpl = fetch) {
981
+ async function request(method, path9, body, horseAuthToken, fetchImpl = fetch, identityOverride) {
927
982
  const url = path9.startsWith("http") ? path9 : `${apiBase()}${path9}`;
928
983
  const headers = {};
929
984
  headers[CLI_VERSION_HEADER] = CLI_VERSION;
930
985
  headers["user-agent"] = `token-derby/${CLI_VERSION}`;
931
- const identity = await getIdentity();
986
+ const identity = identityOverride ?? await getIdentity();
932
987
  if (identity) {
933
988
  headers[USER_ID_HEADER] = identity.user_id;
934
989
  headers[USER_TOKEN_HEADER] = identity.secret_token;
@@ -994,6 +1049,9 @@ function listOrganisations() {
994
1049
  function initJockey(body) {
995
1050
  return request("POST", "/jockey/init", body, void 0);
996
1051
  }
1052
+ function getJockey() {
1053
+ return request("GET", "/jockey/me", void 0, void 0);
1054
+ }
997
1055
  function updateJockey(body) {
998
1056
  return request("PUT", "/jockey/me", body, void 0);
999
1057
  }
@@ -1034,6 +1092,28 @@ function probeClaim(code) {
1034
1092
  function redeemClaim(code, body) {
1035
1093
  return request("POST", `/claims/${encodeURIComponent(code)}/redeem`, body, void 0);
1036
1094
  }
1095
+ function cliAuthStart(body) {
1096
+ return request("POST", "/auth/cli/start", body, void 0);
1097
+ }
1098
+ function cliAuthPoll(body) {
1099
+ return request("POST", "/auth/cli/poll", body, void 0);
1100
+ }
1101
+ function revokeDevice(deviceId, auth) {
1102
+ return request(
1103
+ "DELETE",
1104
+ `/devices/${encodeURIComponent(deviceId)}`,
1105
+ void 0,
1106
+ void 0,
1107
+ void 0,
1108
+ auth
1109
+ );
1110
+ }
1111
+ function registerDevice(body) {
1112
+ return request("POST", "/devices", body, void 0);
1113
+ }
1114
+ function logoutDevice() {
1115
+ return request("DELETE", "/devices/me", void 0, void 0);
1116
+ }
1037
1117
 
1038
1118
  // src/commands/stable-create.ts
1039
1119
  async function stableCreateCommand() {
@@ -2461,7 +2541,7 @@ async function joinCommand(joinCode, argv = []) {
2461
2541
  }
2462
2542
  const identity = await loadIdentity();
2463
2543
  if (!identity) {
2464
- console.error("Run `token-derby init` to set up your identity.");
2544
+ console.error("Run `token-derby login` to set up your identity.");
2465
2545
  return 1;
2466
2546
  }
2467
2547
  let race;
@@ -2634,72 +2714,346 @@ async function endCommand(adminCode) {
2634
2714
  // src/commands/init.ts
2635
2715
  import * as readline4 from "readline/promises";
2636
2716
  import { stdin as stdin4, stdout as stdout4 } from "process";
2637
- async function initCommand(reset = false) {
2638
- if (reset) {
2639
- await deleteIdentity();
2640
- _resetIdentityCacheForTests();
2641
- console.log("Removed local identity. Creating a new one\u2026");
2642
- }
2643
- const existing = await loadIdentity();
2717
+ async function defaultPromptText(question) {
2644
2718
  const rl = readline4.createInterface({ input: stdin4, output: stdout4 });
2645
2719
  try {
2646
- if (existing) {
2647
- console.log(`Current jockey name: ${existing.display_name}`);
2648
- const raw2 = (await rl.question("New jockey name (use your real name please) [keep]: ")).trim();
2649
- if (!raw2) {
2650
- console.log("Kept existing name.");
2720
+ return await rl.question(question);
2721
+ } finally {
2722
+ rl.close();
2723
+ }
2724
+ }
2725
+ async function initCommand(reset = false, deps = {}) {
2726
+ const loadIdentity2 = deps.loadIdentity ?? loadIdentity;
2727
+ const saveIdentity2 = deps.saveIdentity ?? saveIdentity;
2728
+ const deleteIdentity2 = deps.deleteIdentity ?? deleteIdentity;
2729
+ const readIdentityFile2 = deps.readIdentityFile ?? readIdentityFile;
2730
+ const initJockey2 = deps.initJockey ?? initJockey;
2731
+ const updateJockey2 = deps.updateJockey ?? updateJockey;
2732
+ const apiGetJockey = deps.apiGetJockey ?? getJockey;
2733
+ const promptText = deps.promptText ?? defaultPromptText;
2734
+ const isTTY = deps.isTTY ?? Boolean(process.stdin.isTTY);
2735
+ console.log("");
2736
+ console.log(
2737
+ "Heads up: `token-derby login` is becoming the only way to manage accounts on this machine. `init` still works today, but plan to move over."
2738
+ );
2739
+ console.log("");
2740
+ if (reset) {
2741
+ const current = await readIdentityFile2();
2742
+ if (current.kind !== "missing") {
2743
+ if (current.kind === "ok") {
2744
+ console.log(`About to abandon jockey: ${current.identity.display_name}`);
2745
+ } else if (current.kind === "unreadable") {
2746
+ console.log(`About to delete identity.json, which ${current.reason}.`);
2747
+ console.log("The credential inside cannot be read, so there is no way to tell which jockey");
2748
+ console.log("this is, or whether anything else can still recover it.");
2749
+ } else {
2750
+ console.log(`About to delete identity.json, which ${current.reason}.`);
2751
+ }
2752
+ console.log("This deletes the local identity only \u2014 the account itself stays on the server.");
2753
+ if (current.kind === "ok") {
2754
+ try {
2755
+ const me = await apiGetJockey();
2756
+ if (me.device_label) {
2757
+ console.log(` This machine's credential, "${me.device_label}", will stay live on the`);
2758
+ console.log(" abandoned account and can only be revoked from the Account view in the org manager.");
2759
+ }
2760
+ if (me.email) {
2761
+ console.log(" This account has a Google account linked \u2014 `token-derby login` would recover");
2762
+ console.log(" this same jockey instead of abandoning it.");
2763
+ }
2764
+ } catch {
2765
+ }
2766
+ }
2767
+ if (!isTTY) {
2768
+ console.error("");
2769
+ console.error("Refusing to reset without a terminal to confirm \u2014 this is irreversible.");
2770
+ console.error(
2771
+ "Run `token-derby init --reset` interactively, or use `token-derby login` to recover this jockey instead."
2772
+ );
2773
+ return 1;
2774
+ }
2775
+ const answer = (await promptText('Type "yes" to abandon this jockey [no]: ')).trim().toLowerCase();
2776
+ if (answer !== "yes") {
2777
+ console.log("Reset cancelled. Nothing was deleted.");
2651
2778
  return 0;
2652
2779
  }
2653
- const v2 = validateDisplayName(raw2);
2654
- if (!v2.ok) {
2655
- console.error(v2.error);
2780
+ }
2781
+ await deleteIdentity2();
2782
+ _resetIdentityCacheForTests();
2783
+ console.log("Removed local identity. Creating a new one\u2026");
2784
+ }
2785
+ if (!reset) {
2786
+ const state = await readIdentityFile2();
2787
+ if (state.kind === "unreadable") {
2788
+ console.error(`Your local identity file ${state.reason}.`);
2789
+ console.error("It may hold a credential that cannot be recovered, so this will not overwrite it.");
2790
+ console.error("Run `token-derby login` to sign in fresh, or `token-derby init --reset` to discard it.");
2791
+ return 1;
2792
+ }
2793
+ if (state.kind === "no-credential") {
2794
+ console.log(`Your local identity file ${state.reason}, so this will replace it.`);
2795
+ }
2796
+ }
2797
+ const existing = await loadIdentity2();
2798
+ if (existing) {
2799
+ console.log(`Current jockey name: ${existing.display_name}`);
2800
+ const raw2 = (await promptText("New jockey name (use your real name please) [keep]: ")).trim();
2801
+ if (!raw2) {
2802
+ console.log("Kept existing name.");
2803
+ return 0;
2804
+ }
2805
+ const v2 = validateDisplayName(raw2);
2806
+ if (!v2.ok) {
2807
+ console.error(v2.error);
2808
+ return 1;
2809
+ }
2810
+ try {
2811
+ const resp = await updateJockey2({ display_name: v2.name });
2812
+ const updated = { ...existing, display_name: resp.display_name };
2813
+ await saveIdentity2(updated);
2814
+ console.log(`Updated jockey name to: ${updated.display_name}`);
2815
+ return 0;
2816
+ } catch (e) {
2817
+ if (e instanceof ApiError) {
2818
+ if (e.code === "UNAUTHENTICATED") {
2819
+ console.error(
2820
+ "Server does not recognise this identity. Your account may have been wiped. Run `token-derby init --reset` to start fresh."
2821
+ );
2822
+ } else {
2823
+ console.error(`Error: ${e.code} ${e.message}`);
2824
+ }
2656
2825
  return 1;
2657
2826
  }
2658
- try {
2659
- const resp = await updateJockey({ display_name: v2.name });
2660
- const updated = { ...existing, display_name: resp.display_name };
2661
- await saveIdentity(updated);
2662
- console.log(`Updated jockey name to: ${updated.display_name}`);
2827
+ throw e;
2828
+ }
2829
+ }
2830
+ const raw = (await promptText("Jockey Name (use your real name please): ")).trim();
2831
+ const v = validateDisplayName(raw);
2832
+ if (!v.ok) {
2833
+ console.error(v.error);
2834
+ return 1;
2835
+ }
2836
+ try {
2837
+ const resp = await initJockey2({ display_name: v.name });
2838
+ const identity = {
2839
+ user_id: resp.user_id,
2840
+ display_name: resp.display_name,
2841
+ secret_token: resp.secret_token,
2842
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
2843
+ };
2844
+ await saveIdentity2(identity);
2845
+ _resetIdentityCacheForTests();
2846
+ console.log("");
2847
+ console.log(`Welcome, ${identity.display_name}!`);
2848
+ console.log("Your identity has been created on the server.");
2849
+ console.log("You can now create a stable and join races.");
2850
+ console.log("");
2851
+ console.log(" \u26A0 Your secret token is stored locally in identity.json.");
2852
+ console.log(" If you lose it, you cannot recover this account \u2014 you would");
2853
+ console.log(" need to run `token-derby init --reset` and rebuild your stable.");
2854
+ return 0;
2855
+ } catch (e) {
2856
+ if (e instanceof ApiError) {
2857
+ console.error(`Error: ${e.code} ${e.message}`);
2858
+ return 1;
2859
+ }
2860
+ throw e;
2861
+ }
2862
+ }
2863
+
2864
+ // src/commands/login.ts
2865
+ import { spawn as spawn2 } from "child_process";
2866
+ import * as os3 from "os";
2867
+
2868
+ // src/commands/web.ts
2869
+ import { spawn } from "child_process";
2870
+ function webOrigin() {
2871
+ return apiBase().replace(/\/api\/?$/, "");
2872
+ }
2873
+ function opener() {
2874
+ if (process.platform === "darwin") return "open";
2875
+ if (process.platform === "win32") return "start";
2876
+ if (process.platform === "linux") return "xdg-open";
2877
+ return null;
2878
+ }
2879
+ async function webCommand(deps = {}) {
2880
+ const spawnImpl = deps.spawnImpl ?? spawn;
2881
+ let code;
2882
+ try {
2883
+ ({ code } = await createWebSession());
2884
+ } catch (e) {
2885
+ if (e instanceof ApiError) {
2886
+ console.error(`Error: ${e.code} ${e.message}`);
2887
+ return 1;
2888
+ }
2889
+ throw e;
2890
+ }
2891
+ const url = `${webOrigin()}/org-manager#code=${code}`;
2892
+ console.log("");
2893
+ console.log(" Opening the Token Derby org manager in your browser...");
2894
+ console.log(` ${url}`);
2895
+ console.log("");
2896
+ console.log(" If it doesn't open, copy the link above. It expires in 60 seconds.");
2897
+ const cmd = opener();
2898
+ if (cmd) {
2899
+ try {
2900
+ const child = spawnImpl(cmd, [url], { stdio: "ignore", detached: true });
2901
+ child.on("error", () => {
2902
+ });
2903
+ child.unref();
2904
+ } catch {
2905
+ }
2906
+ }
2907
+ return 0;
2908
+ }
2909
+
2910
+ // src/ui/prompt.ts
2911
+ async function promptYesNo(question) {
2912
+ resetStdinAfterInk();
2913
+ const readline6 = await import("readline/promises");
2914
+ const rl = readline6.createInterface({ input: process.stdin, output: process.stdout });
2915
+ const a = (await rl.question(question)).trim().toLowerCase();
2916
+ rl.close();
2917
+ return a === "" || a === "y" || a === "yes";
2918
+ }
2919
+ function resetStdinAfterInk() {
2920
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
2921
+ process.stdin.setRawMode(false);
2922
+ }
2923
+ while (process.stdin.read() !== null) {
2924
+ }
2925
+ process.stdin.pause();
2926
+ process.stdin.ref();
2927
+ }
2928
+
2929
+ // src/commands/login.ts
2930
+ function parseDeviceNameFlag(argv) {
2931
+ for (let i = 0; i < argv.length; i++) {
2932
+ const a = argv[i];
2933
+ if (a === "--device-name") return argv[i + 1] ?? null;
2934
+ if (a.startsWith("--device-name=")) return a.slice("--device-name=".length);
2935
+ }
2936
+ return null;
2937
+ }
2938
+ async function defaultPromptText2(question) {
2939
+ const readline6 = await import("readline/promises");
2940
+ const rl = readline6.createInterface({ input: process.stdin, output: process.stdout });
2941
+ try {
2942
+ return await rl.question(question);
2943
+ } finally {
2944
+ rl.close();
2945
+ }
2946
+ }
2947
+ function defaultSleep(ms) {
2948
+ return new Promise((resolve) => setTimeout(resolve, ms));
2949
+ }
2950
+ async function resolveDeviceName(flagName, isTTY, hostname3, promptText) {
2951
+ if (flagName) return flagName;
2952
+ if (!isTTY) return hostname3;
2953
+ const answer = (await promptText(`Device name [${hostname3}]: `)).trim();
2954
+ return answer || hostname3;
2955
+ }
2956
+ async function loginCommand(argv = [], deps = {}) {
2957
+ const apiStart = deps.apiStart ?? cliAuthStart;
2958
+ const apiPoll = deps.apiPoll ?? cliAuthPoll;
2959
+ const apiRevokeDevice = deps.apiRevokeDevice ?? revokeDevice;
2960
+ const apiCreateWebSession = deps.apiCreateWebSession ?? createWebSession;
2961
+ const saveIdentity2 = deps.saveIdentity ?? saveIdentity;
2962
+ const loadIdentity2 = deps.loadIdentity ?? loadIdentity;
2963
+ const promptText = deps.promptText ?? defaultPromptText2;
2964
+ const promptYesNo2 = deps.promptYesNo ?? promptYesNo;
2965
+ const sleepImpl = deps.sleepImpl ?? defaultSleep;
2966
+ const isTTY = deps.isTTY ?? Boolean(process.stdin.isTTY);
2967
+ const spawnImpl = deps.spawnImpl ?? spawn2;
2968
+ const hostname3 = deps.hostname ?? (() => os3.hostname());
2969
+ const existing = await loadIdentity2();
2970
+ if (existing) {
2971
+ console.log("");
2972
+ console.log(` This machine is already signed in as ${existing.display_name}.`);
2973
+ console.log(" Signing in again adds a second credential; the current one stays active");
2974
+ console.log(" until you revoke it under Account in the org manager (`token-derby web`).");
2975
+ if (isTTY) {
2976
+ if (!await promptYesNo2(" Sign in again anyway? [Y/n] ")) {
2977
+ console.log("Login cancelled.");
2663
2978
  return 0;
2664
- } catch (e) {
2665
- if (e instanceof ApiError) {
2666
- if (e.code === "UNAUTHENTICATED") {
2667
- console.error(
2668
- "Server does not recognise this identity. Your account may have been wiped. Run `token-derby init --reset` to start fresh."
2669
- );
2670
- } else {
2671
- console.error(`Error: ${e.code} ${e.message}`);
2672
- }
2979
+ }
2980
+ } else {
2981
+ console.log(" No TTY to confirm \u2014 continuing (equivalent to answering Y).");
2982
+ }
2983
+ }
2984
+ let label = await resolveDeviceName(parseDeviceNameFlag(argv), isTTY, hostname3(), promptText);
2985
+ let start;
2986
+ for (; ; ) {
2987
+ try {
2988
+ start = await apiStart({ label });
2989
+ break;
2990
+ } catch (e) {
2991
+ if (e instanceof ApiError && e.code === "BAD_REQUEST") {
2992
+ console.error(`Error: ${e.message}`);
2993
+ if (!isTTY) return 1;
2994
+ const retry = (await promptText("Enter a different device name: ")).trim();
2995
+ if (!retry) {
2996
+ console.error("Device name cannot be empty.");
2673
2997
  return 1;
2674
2998
  }
2999
+ label = retry;
3000
+ continue;
3001
+ }
3002
+ if (e instanceof ApiError) {
3003
+ console.error(`Error: ${e.code} ${e.message}`);
3004
+ return 1;
3005
+ }
3006
+ throw e;
3007
+ }
3008
+ }
3009
+ let verificationUrl = start.verification_uri;
3010
+ let hasGrant = false;
3011
+ if (existing) {
3012
+ try {
3013
+ const grant = await apiCreateWebSession();
3014
+ verificationUrl = `${start.verification_uri}#code=${grant.code}`;
3015
+ hasGrant = true;
3016
+ } catch (e) {
3017
+ if (e instanceof ApiError && e.code === "UNAUTHENTICATED") {
3018
+ console.log("");
3019
+ console.log(" The credential stored on this machine is no longer valid (it may have been");
3020
+ console.log(" revoked), so this will sign you in fresh in the browser.");
3021
+ } else if (e instanceof ApiError) {
3022
+ console.error(`Error: ${e.code} ${e.message}`);
3023
+ return 1;
3024
+ } else {
2675
3025
  throw e;
2676
3026
  }
2677
3027
  }
2678
- const raw = (await rl.question("Jockey Name (use your real name please): ")).trim();
2679
- const v = validateDisplayName(raw);
2680
- if (!v.ok) {
2681
- console.error(v.error);
2682
- return 1;
3028
+ }
3029
+ console.log("");
3030
+ console.log(" To finish signing in, visit:");
3031
+ console.log(` ${verificationUrl}`);
3032
+ if (hasGrant) {
3033
+ console.log(" That link signs the browser in for you and expires in 60 seconds \u2014 if it");
3034
+ console.log(" does, run `token-derby login` again for a fresh one.");
3035
+ }
3036
+ console.log("");
3037
+ console.log(" And enter this code:");
3038
+ console.log(` ${start.user_code}`);
3039
+ console.log("");
3040
+ console.log(" Waiting for approval...");
3041
+ const cmd = opener();
3042
+ if (cmd) {
3043
+ try {
3044
+ const child = spawnImpl(cmd, [verificationUrl], { stdio: "ignore", detached: true });
3045
+ child.on("error", () => {
3046
+ });
3047
+ child.unref();
3048
+ } catch {
2683
3049
  }
3050
+ }
3051
+ const deadline = Date.now() + start.expires_in * 1e3;
3052
+ let approved = null;
3053
+ while (!approved) {
3054
+ let poll;
2684
3055
  try {
2685
- const resp = await initJockey({ display_name: v.name });
2686
- const identity = {
2687
- user_id: resp.user_id,
2688
- display_name: resp.display_name,
2689
- secret_token: resp.secret_token,
2690
- created_at: (/* @__PURE__ */ new Date()).toISOString()
2691
- };
2692
- await saveIdentity(identity);
2693
- _resetIdentityCacheForTests();
2694
- console.log("");
2695
- console.log(`Welcome, ${identity.display_name}!`);
2696
- console.log("Your identity has been created on the server.");
2697
- console.log("You can now create a stable and join races.");
2698
- console.log("");
2699
- console.log(" \u26A0 Your secret token is stored locally in identity.json.");
2700
- console.log(" If you lose it, you cannot recover this account \u2014 you would");
2701
- console.log(" need to run `token-derby init --reset` and rebuild your stable.");
2702
- return 0;
3056
+ poll = await apiPoll({ device_code: start.device_code });
2703
3057
  } catch (e) {
2704
3058
  if (e instanceof ApiError) {
2705
3059
  console.error(`Error: ${e.code} ${e.message}`);
@@ -2707,21 +3061,292 @@ async function initCommand(reset = false) {
2707
3061
  }
2708
3062
  throw e;
2709
3063
  }
3064
+ if (poll.status === "approved") {
3065
+ approved = poll;
3066
+ break;
3067
+ }
3068
+ if (Date.now() >= deadline) {
3069
+ console.error("Login timed out waiting for approval. Run `token-derby login` again.");
3070
+ return 1;
3071
+ }
3072
+ await sleepImpl(start.interval * 1e3);
3073
+ }
3074
+ console.log("");
3075
+ if (approved.email) console.log(` \u2713 Signed in as ${approved.email}`);
3076
+ console.log(
3077
+ ` Linking to your existing jockey: ${approved.display_name} (${approved.horses} horses, ${approved.orgs} orgs)`
3078
+ );
3079
+ let proceed;
3080
+ if (isTTY) {
3081
+ proceed = await promptYesNo2(" Link this account? [Y/n] ");
3082
+ } else {
3083
+ console.log(" No TTY to confirm \u2014 proceeding automatically (equivalent to answering Y).");
3084
+ proceed = true;
3085
+ }
3086
+ if (!proceed) {
3087
+ try {
3088
+ await apiRevokeDevice(approved.device_id, { user_id: approved.user_id, secret_token: approved.secret_token });
3089
+ } catch (e) {
3090
+ console.error("Declined, but could not revoke the device credential on the server.");
3091
+ if (e instanceof ApiError) console.error(`Error: ${e.code} ${e.message}`);
3092
+ return 1;
3093
+ }
3094
+ console.log("Login cancelled.");
3095
+ return 0;
3096
+ }
3097
+ const identity = {
3098
+ user_id: approved.user_id,
3099
+ display_name: approved.display_name,
3100
+ secret_token: approved.secret_token,
3101
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
3102
+ };
3103
+ await saveIdentity2(identity);
3104
+ console.log("");
3105
+ console.log(`Welcome back, ${identity.display_name}!`);
3106
+ return 0;
3107
+ }
3108
+
3109
+ // src/commands/logout.ts
3110
+ async function logoutCommand(deps = {}) {
3111
+ const apiLogoutDevice = deps.apiLogoutDevice ?? logoutDevice;
3112
+ const deleteIdentity2 = deps.deleteIdentity ?? deleteIdentity;
3113
+ try {
3114
+ const result = await apiLogoutDevice();
3115
+ if (result.revoked) {
3116
+ console.log("Revoked this device's credential on the server.");
3117
+ } else {
3118
+ console.log(
3119
+ "This machine is signed in with a legacy account credential (never linked via `token-derby login`), so there is no separate device credential to revoke."
3120
+ );
3121
+ }
3122
+ } catch (e) {
3123
+ if (e instanceof ApiError && e.code === "UNAUTHENTICATED") {
3124
+ console.error("This device credential was already invalid on the server \u2014 clearing it locally too.");
3125
+ await deleteIdentity2();
3126
+ console.log("Removed local identity. You are signed out.");
3127
+ return 0;
3128
+ }
3129
+ if (e instanceof ApiError) {
3130
+ console.error(`Error: ${e.code} ${e.message}`);
3131
+ console.error("Local identity left in place \u2014 run `token-derby logout` again to retry.");
3132
+ return 1;
3133
+ }
3134
+ throw e;
3135
+ }
3136
+ await deleteIdentity2();
3137
+ console.log("Removed local identity. You are signed out.");
3138
+ return 0;
3139
+ }
3140
+
3141
+ // src/commands/link.ts
3142
+ import { spawn as spawn3 } from "child_process";
3143
+ import * as os4 from "os";
3144
+
3145
+ // src/ui/messages.ts
3146
+ var CREDENTIAL_DEAD_MESSAGE = "This machine's credential is no longer valid on the server \u2014 it may have been revoked.\nRun `token-derby login` to sign this machine in again.";
3147
+
3148
+ // src/commands/link.ts
3149
+ function defaultSleep2(ms) {
3150
+ return new Promise((resolve) => setTimeout(resolve, ms));
3151
+ }
3152
+ async function defaultPromptText3(question) {
3153
+ const readline6 = await import("readline/promises");
3154
+ const rl = readline6.createInterface({ input: process.stdin, output: process.stdout });
3155
+ try {
3156
+ return await rl.question(question);
2710
3157
  } finally {
2711
3158
  rl.close();
2712
3159
  }
2713
3160
  }
3161
+ var POLL_INTERVAL_MS = 2e3;
3162
+ var WAIT_TIMEOUT_MS = 5 * 60 * 1e3;
3163
+ async function linkCommand(argv = [], deps = {}) {
3164
+ const apiGetJockey = deps.apiGetJockey ?? getJockey;
3165
+ const apiCreateWebSession = deps.apiCreateWebSession ?? createWebSession;
3166
+ const apiRegisterDevice = deps.apiRegisterDevice ?? registerDevice;
3167
+ const loadIdentity2 = deps.loadIdentity ?? loadIdentity;
3168
+ const saveIdentity2 = deps.saveIdentity ?? saveIdentity;
3169
+ const spawnImpl = deps.spawnImpl ?? spawn3;
3170
+ const sleepImpl = deps.sleepImpl ?? defaultSleep2;
3171
+ const promptText = deps.promptText ?? defaultPromptText3;
3172
+ const isTTY = deps.isTTY ?? Boolean(process.stdin.isTTY);
3173
+ const hostname3 = deps.hostname ?? (() => os4.hostname());
3174
+ const flagName = parseDeviceNameFlag(argv);
3175
+ if (flagName !== null) {
3176
+ const checked = validateDeviceLabel(flagName);
3177
+ if (!checked.ok) {
3178
+ console.error(`Error: invalid --device-name: ${checked.message}`);
3179
+ return 1;
3180
+ }
3181
+ }
3182
+ const syncLocalName = async (serverName) => {
3183
+ const local = await loadIdentity2();
3184
+ if (!local || local.display_name === serverName) return;
3185
+ await saveIdentity2({ ...local, display_name: serverName });
3186
+ };
3187
+ const registerThisMachine = async (linked) => {
3188
+ const serverName = linked.display_name;
3189
+ if (linked.device_label) {
3190
+ await syncLocalName(serverName);
3191
+ console.log(` This machine already has its own credential, "${linked.device_label}", so it`);
3192
+ console.log(" was not registered again \u2014 a second one would leave the first live with");
3193
+ console.log(" nothing on this machine holding it.");
3194
+ return;
3195
+ }
3196
+ const local = await loadIdentity2();
3197
+ if (!local) {
3198
+ console.log(" This machine has no local identity file, so it was not registered as a");
3199
+ console.log(" device. Run `token-derby login` to give it its own credential.");
3200
+ return;
3201
+ }
3202
+ const named = local.display_name === serverName ? local : { ...local, display_name: serverName };
3203
+ let label;
3204
+ try {
3205
+ label = await resolveDeviceName(flagName, isTTY, hostname3(), promptText);
3206
+ for (; ; ) {
3207
+ try {
3208
+ const registered = await apiRegisterDevice({ label });
3209
+ await saveIdentity2({ ...named, secret_token: registered.secret_token });
3210
+ break;
3211
+ } catch (e) {
3212
+ if (!(e instanceof ApiError && e.code === "BAD_REQUEST" && isTTY)) throw e;
3213
+ console.error(` Error: ${e.message}`);
3214
+ const retry = (await promptText(" Enter a different device name: ")).trim();
3215
+ if (!retry) throw e;
3216
+ label = retry;
3217
+ }
3218
+ }
3219
+ } catch (e) {
3220
+ try {
3221
+ if (named !== local) await saveIdentity2(named);
3222
+ } catch {
3223
+ }
3224
+ console.log("");
3225
+ console.log(" Your account is linked \u2014 that part is done and does not need repeating.");
3226
+ console.log(" Registering this machine as a device did not complete, so it is still");
3227
+ console.log(" using the shared account credential. Run `token-derby login` to finish.");
3228
+ console.error(` Error: ${e instanceof ApiError ? `${e.code} ${e.message}` : String(e)}`);
3229
+ return;
3230
+ }
3231
+ console.log(` This machine is now registered as "${label}", with a credential of its own`);
3232
+ console.log(" that you can revoke separately under Account in the org manager.");
3233
+ };
3234
+ let me;
3235
+ try {
3236
+ me = await apiGetJockey();
3237
+ } catch (e) {
3238
+ if (e instanceof ApiError) {
3239
+ if (e.code === "UNAUTHENTICATED") {
3240
+ console.error(CREDENTIAL_DEAD_MESSAGE);
3241
+ return 1;
3242
+ }
3243
+ console.error(`Error: ${e.code} ${e.message}`);
3244
+ return 1;
3245
+ }
3246
+ throw e;
3247
+ }
3248
+ if (me.email) {
3249
+ console.log(`Already linked to ${me.email}.`);
3250
+ await syncLocalName(me.display_name);
3251
+ return 0;
3252
+ }
3253
+ let code;
3254
+ try {
3255
+ ({ code } = await apiCreateWebSession());
3256
+ } catch (e) {
3257
+ if (e instanceof ApiError) {
3258
+ console.error(`Error: ${e.code} ${e.message}`);
3259
+ return 1;
3260
+ }
3261
+ throw e;
3262
+ }
3263
+ const url = `${webOrigin()}/link#code=${code}`;
3264
+ console.log("");
3265
+ console.log(" Opening the Google link page in your browser...");
3266
+ console.log(` ${url}`);
3267
+ console.log("");
3268
+ console.log(" If it doesn't open, copy the link above. It expires in 60 seconds.");
3269
+ const cmd = opener();
3270
+ if (cmd) {
3271
+ try {
3272
+ const child = spawnImpl(cmd, [url], { stdio: "ignore", detached: true });
3273
+ child.on("error", () => {
3274
+ });
3275
+ child.unref();
3276
+ } catch {
3277
+ }
3278
+ }
3279
+ console.log("");
3280
+ console.log(" Waiting for you to finish connecting with Google...");
3281
+ const deadline = Date.now() + WAIT_TIMEOUT_MS;
3282
+ for (; ; ) {
3283
+ let poll;
3284
+ try {
3285
+ poll = await apiGetJockey();
3286
+ } catch (e) {
3287
+ if (e instanceof ApiError) {
3288
+ if (e.code === "UNAUTHENTICATED") {
3289
+ console.error(CREDENTIAL_DEAD_MESSAGE);
3290
+ return 1;
3291
+ }
3292
+ console.error(`Error: ${e.code} ${e.message}`);
3293
+ return 1;
3294
+ }
3295
+ throw e;
3296
+ }
3297
+ if (poll.email) {
3298
+ console.log(`\u2713 Linked to ${poll.email}.`);
3299
+ if (poll.display_name !== me.display_name) {
3300
+ console.log(` Your jockey is now named ${poll.display_name} \u2014 a first link renames it to`);
3301
+ console.log(" the first name on the Google account. `token-derby init` renames it again");
3302
+ console.log(" if you would rather it said something else.");
3303
+ }
3304
+ await registerThisMachine(poll);
3305
+ return 0;
3306
+ }
3307
+ if (Date.now() >= deadline) {
3308
+ console.error("Timed out waiting for the Google link to complete. Run `token-derby link` again.");
3309
+ return 1;
3310
+ }
3311
+ await sleepImpl(POLL_INTERVAL_MS);
3312
+ }
3313
+ }
3314
+
3315
+ // src/commands/whoami.ts
3316
+ async function whoamiCommand(deps = {}) {
3317
+ const apiGetJockey = deps.apiGetJockey ?? getJockey;
3318
+ let me;
3319
+ try {
3320
+ me = await apiGetJockey();
3321
+ } catch (e) {
3322
+ if (e instanceof ApiError) {
3323
+ if (e.code === "UNAUTHENTICATED") {
3324
+ console.error(CREDENTIAL_DEAD_MESSAGE);
3325
+ return 1;
3326
+ }
3327
+ console.error(`Error: ${e.code} ${e.message}`);
3328
+ return 1;
3329
+ }
3330
+ throw e;
3331
+ }
3332
+ console.log("");
3333
+ console.log(` ${me.display_name}`);
3334
+ if (me.email) console.log(` ${me.email}`);
3335
+ if (me.device_label) console.log(` this machine: ${me.device_label}`);
3336
+ console.log("");
3337
+ return 0;
3338
+ }
2714
3339
 
2715
3340
  // src/commands/update.ts
2716
3341
  import * as readline5 from "readline/promises";
2717
3342
  import { stdin as stdin5, stdout as stdout5 } from "process";
2718
- import { spawn } from "child_process";
3343
+ import { spawn as spawn4 } from "child_process";
2719
3344
  var REGISTRY_URL = "https://registry.npmjs.org/@mauricode/token-derby/latest";
2720
3345
  var UPGRADE_CMD = "npm install -g @mauricode/token-derby@latest";
2721
3346
  var FETCH_TIMEOUT_MS = 5e3;
2722
3347
  async function updateCommand(deps = {}) {
2723
3348
  const fetchImpl = deps.fetchImpl ?? fetch;
2724
- const spawnImpl = deps.spawnImpl ?? spawn;
3349
+ const spawnImpl = deps.spawnImpl ?? spawn4;
2725
3350
  const promptYesNo2 = deps.promptYesNo ?? defaultPromptYesNo;
2726
3351
  let latest;
2727
3352
  try {
@@ -2857,25 +3482,6 @@ function RollHorsePicker({ horses, onPick, onCancel }) {
2857
3482
  ] });
2858
3483
  }
2859
3484
 
2860
- // src/ui/prompt.ts
2861
- async function promptYesNo(question) {
2862
- resetStdinAfterInk();
2863
- const readline6 = await import("readline/promises");
2864
- const rl = readline6.createInterface({ input: process.stdin, output: process.stdout });
2865
- const a = (await rl.question(question)).trim().toLowerCase();
2866
- rl.close();
2867
- return a === "" || a === "y" || a === "yes";
2868
- }
2869
- function resetStdinAfterInk() {
2870
- if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
2871
- process.stdin.setRawMode(false);
2872
- }
2873
- while (process.stdin.read() !== null) {
2874
- }
2875
- process.stdin.pause();
2876
- process.stdin.ref();
2877
- }
2878
-
2879
3485
  // src/ui/reveal.ts
2880
3486
  import React13 from "react";
2881
3487
  import { render as render5 } from "ink";
@@ -2889,13 +3495,13 @@ import { useEffect as useEffect3, useState as useState7 } from "react";
2889
3495
  import { Box as Box10, Text as Text10 } from "ink";
2890
3496
  import { jsx as jsx10 } from "react/jsx-runtime";
2891
3497
  function HatSprite({ hat, variant, centerIn }) {
2892
- const colors = hatColorsFor2(hat, variant ?? 0);
3498
+ const colors = hatColors(hat, variant ?? 0);
2893
3499
  const grid = makeHatGrid(hat, colors, centerIn);
2894
3500
  const lines = hexGridToHalfBlocks(grid);
2895
3501
  return /* @__PURE__ */ jsx10(Box10, { flexDirection: "column", children: lines.map((line, i) => /* @__PURE__ */ jsx10(Text10, { children: line }, i)) });
2896
3502
  }
2897
3503
  function AnimatedHatSprite({ hat, variant, centerIn }) {
2898
- if (hat.rarity !== "legendary") {
3504
+ if (!isAnimatedHat(hat)) {
2899
3505
  return /* @__PURE__ */ jsx10(HatSprite, { hat, variant, centerIn });
2900
3506
  }
2901
3507
  const frames = hat.animation.frames;
@@ -2912,10 +3518,6 @@ function AnimatedHatSprite({ hat, variant, centerIn }) {
2912
3518
  const framed = { ...hat, colors: { ...hat.colors, A: frames[idx] } };
2913
3519
  return /* @__PURE__ */ jsx10(HatSprite, { hat: framed, variant, centerIn });
2914
3520
  }
2915
- function hatColorsFor2(hat, variantIdx) {
2916
- if (hat.rarity === "legendary") return hat.colors;
2917
- return hat.variants[variantIdx] ?? hat.variants[0];
2918
- }
2919
3521
  function makeHatGrid(hat, colors, centerIn) {
2920
3522
  const w = centerIn?.w ?? hat.width;
2921
3523
  const h = centerIn?.h ?? hat.rows.length;
@@ -2930,7 +3532,7 @@ function makeHatGrid(hat, colors, centerIn) {
2930
3532
  const gx = x + offX;
2931
3533
  const gy = y + offY;
2932
3534
  if (gx < 0 || gx >= w || gy < 0 || gy >= h) continue;
2933
- grid[gy][gx] = ch === "A" ? colors.A : colors.Q ?? colors.A;
3535
+ grid[gy][gx] = channelColor(colors, ch);
2934
3536
  }
2935
3537
  }
2936
3538
  return grid;
@@ -2944,7 +3546,8 @@ var TIER_PALETTE = {
2944
3546
  common: ["#FFFFFF", "#DDDDDD", "#AAAAAA", "#9E9E9E"],
2945
3547
  rare: ["#42A5F5", "#1E88E5", "#90CAF9", "#0277BD"],
2946
3548
  epic: ["#AB47BC", "#8E24AA", "#CE93D8", "#6A1B9A"],
2947
- legendary: ["#FFD700", "#FF7F00", "#FF0000", "#FF00FF", "#00BFFF", "#7CFC00", "#8B00FF"]
3549
+ legendary: ["#FFD700", "#FF7F00", "#FF0000", "#FF00FF", "#00BFFF", "#7CFC00", "#8B00FF"],
3550
+ limited: ["#F472B6", "#EC4899", "#FF4FA3", "#FBCFE8", "#FFFFFF"]
2948
3551
  };
2949
3552
  var CONFETTI_CHARS = ["\u2726", "\u2727", "\u22C6", "\u2605", "\u2606", "\u2728", "*", "\u2022", "\u25C6", "\u25C7"];
2950
3553
  var SCENE_W = 32;
@@ -3047,7 +3650,7 @@ function ConfettiBurst({ tier }) {
3047
3650
  var CLOSED_HOLD_MS = 3e3;
3048
3651
  function RollReveal({ outcome, onDone }) {
3049
3652
  const isNoHat = outcome.kind === "no_hat";
3050
- const isLegendary = outcome.kind !== "no_hat" && outcome.hat.rarity === "legendary";
3653
+ const isShowpiece = outcome.kind !== "no_hat" && (outcome.hat.rarity === "legendary" || outcome.hat.rarity === "limited");
3051
3654
  const [phase, setPhase] = useState8("closed");
3052
3655
  useEffect4(() => {
3053
3656
  const timers = [];
@@ -3059,10 +3662,10 @@ function RollReveal({ outcome, onDone }) {
3059
3662
  } else {
3060
3663
  timers.push(setTimeout(() => setPhase("burst"), CLOSED_HOLD_MS + 700));
3061
3664
  timers.push(setTimeout(() => setPhase("reveal"), CLOSED_HOLD_MS + 1650));
3062
- timers.push(setTimeout(onDone, CLOSED_HOLD_MS + (isLegendary ? 4650 : 2650)));
3665
+ timers.push(setTimeout(onDone, CLOSED_HOLD_MS + (isShowpiece ? 4650 : 2650)));
3063
3666
  }
3064
3667
  return () => timers.forEach(clearTimeout);
3065
- }, [isNoHat, isLegendary, onDone]);
3668
+ }, [isNoHat, isShowpiece, onDone]);
3066
3669
  if (phase === "closed") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_CLOSED, color: BOX_COLOR });
3067
3670
  if (phase === "open1") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_OPENING_1, color: BOX_COLOR });
3068
3671
  if (phase === "open2") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_OPENING_2, color: BOX_COLOR });
@@ -3071,7 +3674,7 @@ function RollReveal({ outcome, onDone }) {
3071
3674
  return /* @__PURE__ */ jsx11(ConfettiBurst, { tier: outcome.hat.rarity });
3072
3675
  }
3073
3676
  if (outcome.kind === "no_hat") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_EMPTY, color: BOX_COLOR });
3074
- return outcome.hat.rarity === "legendary" ? /* @__PURE__ */ jsx11(AnimatedHatSprite, { hat: outcome.hat, centerIn: { w: SCENE_W, h: SCENE_H } }) : /* @__PURE__ */ jsx11(HatSprite, { hat: outcome.hat, variant: outcome.variant, centerIn: { w: SCENE_W, h: SCENE_H } });
3677
+ return isAnimatedHat(outcome.hat) ? /* @__PURE__ */ jsx11(AnimatedHatSprite, { hat: outcome.hat, centerIn: { w: SCENE_W, h: SCENE_H } }) : /* @__PURE__ */ jsx11(HatSprite, { hat: outcome.hat, variant: outcome.variant, centerIn: { w: SCENE_W, h: SCENE_H } });
3075
3678
  }
3076
3679
 
3077
3680
  // src/ui/reveal.ts
@@ -3162,7 +3765,7 @@ async function rollCommand() {
3162
3765
  await runReveal(outcome);
3163
3766
  if (result.result === "hat") {
3164
3767
  const hat = hatById(result.collected.id);
3165
- const variantSuffix = hat.rarity !== "legendary" && result.collected.variant !== void 0 ? ` #${result.collected.variant + 1}` : "";
3768
+ const variantSuffix = !isAnimatedHat(hat) && result.collected.variant !== void 0 ? ` #${result.collected.variant + 1}` : "";
3166
3769
  console.log(`
3167
3770
  \u2728 ${hat.name}${variantSuffix} [${hat.rarity.toUpperCase()}]
3168
3771
  `);
@@ -3200,8 +3803,9 @@ async function claimCommand(token) {
3200
3803
  console.error("Usage: token-derby claim <token>");
3201
3804
  return 2;
3202
3805
  }
3806
+ let probe;
3203
3807
  try {
3204
- await probeClaim(token);
3808
+ probe = await probeClaim(token);
3205
3809
  } catch (e) {
3206
3810
  if (e instanceof ApiError) {
3207
3811
  console.error(`Error: ${e.code} ${e.message}`);
@@ -3223,7 +3827,9 @@ async function claimCommand(token) {
3223
3827
  console.error("No horses in your stable. Run `token-derby stable create` first.");
3224
3828
  return 1;
3225
3829
  }
3226
- console.log("\n\u{1F381} A cosmetic has been awarded to you.\n");
3830
+ console.log(probe.entry_count > 1 ? `
3831
+ \u{1F381} A pack of ${probe.entry_count} cosmetics \u2014 one of them will be yours.
3832
+ ` : "\n\u{1F381} A cosmetic has been awarded to you.\n");
3227
3833
  const picked = await new Promise((resolve) => {
3228
3834
  const app = render7(React15.createElement(HorsePicker, {
3229
3835
  horses: stable.horses,
@@ -3267,7 +3873,7 @@ async function claimCommand(token) {
3267
3873
  await runReveal(outcome);
3268
3874
  if (result.result === "hat") {
3269
3875
  const hat2 = hatById(result.collected.id);
3270
- const variantSuffix2 = hat2.rarity !== "legendary" && result.collected.variant !== void 0 ? ` #${result.collected.variant + 1}` : "";
3876
+ const variantSuffix2 = !isAnimatedHat(hat2) && result.collected.variant !== void 0 ? ` #${result.collected.variant + 1}` : "";
3271
3877
  console.log(`
3272
3878
  \u2728 ${hat2.name}${variantSuffix2} [${hat2.rarity.toUpperCase()}]
3273
3879
  `);
@@ -3293,12 +3899,9 @@ ${picked.name} already has ${hat?.name ?? result.hat_id}${variantSuffix}. +${res
3293
3899
 
3294
3900
  // src/commands/org-join.ts
3295
3901
  async function orgJoinCommand(token) {
3296
- if (!token) {
3297
- console.error("Usage: token-derby organisation join <join-token>");
3298
- return 2;
3299
- }
3902
+ const join_token = token?.trim();
3300
3903
  try {
3301
- const resp = await joinOrganisation({ join_token: token });
3904
+ const resp = await joinOrganisation(join_token ? { join_token } : {});
3302
3905
  console.log(`Joined organisation: ${resp.org_name}`);
3303
3906
  return 0;
3304
3907
  } catch (e) {
@@ -3310,48 +3913,6 @@ async function orgJoinCommand(token) {
3310
3913
  }
3311
3914
  }
3312
3915
 
3313
- // src/commands/web.ts
3314
- import { spawn as spawn2 } from "child_process";
3315
- function webOrigin() {
3316
- return apiBase().replace(/\/api\/?$/, "");
3317
- }
3318
- function opener() {
3319
- if (process.platform === "darwin") return "open";
3320
- if (process.platform === "win32") return "start";
3321
- if (process.platform === "linux") return "xdg-open";
3322
- return null;
3323
- }
3324
- async function webCommand(deps = {}) {
3325
- const spawnImpl = deps.spawnImpl ?? spawn2;
3326
- let code;
3327
- try {
3328
- ({ code } = await createWebSession());
3329
- } catch (e) {
3330
- if (e instanceof ApiError) {
3331
- console.error(`Error: ${e.code} ${e.message}`);
3332
- return 1;
3333
- }
3334
- throw e;
3335
- }
3336
- const url = `${webOrigin()}/org-manager#code=${code}`;
3337
- console.log("");
3338
- console.log(" Opening the Token Derby org manager in your browser...");
3339
- console.log(` ${url}`);
3340
- console.log("");
3341
- console.log(" If it doesn't open, copy the link above. It expires in 60 seconds.");
3342
- const cmd = opener();
3343
- if (cmd) {
3344
- try {
3345
- const child = spawnImpl(cmd, [url], { stdio: "ignore", detached: true });
3346
- child.on("error", () => {
3347
- });
3348
- child.unref();
3349
- } catch {
3350
- }
3351
- }
3352
- return 0;
3353
- }
3354
-
3355
3916
  // src/commands/env.ts
3356
3917
  function warnOverrides() {
3357
3918
  if (process.env.TOKEN_DERBY_HOME) {
@@ -3386,10 +3947,27 @@ function envCommand(arg) {
3386
3947
  var HELP = `token-derby v${CLI_VERSION}
3387
3948
 
3388
3949
  Identity:
3389
- token-derby init Set up your jockey identity (run this first)
3390
- Re-running renames you on the server.
3391
- token-derby init --reset Wipe local identity and create a fresh account.
3392
- Your previous stable is abandoned on the server.
3950
+ token-derby init Deprecated \u2014 creates or renames your jockey identity;
3951
+ prefer \`login\`, which also links your account.
3952
+ token-derby init --reset Deprecated \u2014 wipes local identity and starts a fresh
3953
+ account (previous stable abandoned); \`login\` recovers
3954
+ your existing account instead of abandoning it.
3955
+ token-derby login [--device-name <name>]
3956
+ About THIS MACHINE: sign in with Google to give
3957
+ it a credential of its own. The way in for a
3958
+ machine that has none yet. Adds no email to
3959
+ your account \u2014 see \`link\`.
3960
+ token-derby logout Retire this machine's credential and clear
3961
+ local identity.
3962
+ token-derby link [--device-name <name>]
3963
+ About YOUR JOCKEY: connect it to a Google
3964
+ account \u2014 adds your email on file, and
3965
+ renames your jockey to the first name on
3966
+ that account. Then registers this machine
3967
+ as a device too, if it is still using the
3968
+ shared account credential.
3969
+ token-derby whoami Show your jockey name, linked email (if any),
3970
+ and this machine's device name (if any).
3393
3971
 
3394
3972
  Maintenance:
3395
3973
  token-derby update Check for and install the latest CLI version
@@ -3401,7 +3979,9 @@ Stable management:
3401
3979
  token-derby stable delete <name> Remove a horse from your stable
3402
3980
 
3403
3981
  Organisations:
3404
- token-derby organisation join <token> Join an organisation with a join token
3982
+ token-derby organisation join [token] Join an organisation with a join token,
3983
+ or leave the token out to join by your
3984
+ verified email domain
3405
3985
  token-derby web Open the web org manager (create orgs,
3406
3986
  manage schedules, webhooks, members)
3407
3987
 
@@ -3442,11 +4022,12 @@ async function main() {
3442
4022
  const reset = argv.slice(1).includes("--reset");
3443
4023
  return initCommand(reset);
3444
4024
  }
4025
+ if (cmd === "login") return loginCommand(argv.slice(1));
3445
4026
  if (cmd === "update") return updateCommand();
3446
4027
  if (cmd === "env") return envCommand(argv[1]);
3447
4028
  const identity = await loadIdentity();
3448
4029
  if (!identity) {
3449
- console.error("Run `token-derby init` to set up your identity before using any other command.");
4030
+ console.error("Run `token-derby login` to set up your identity before using any other command.");
3450
4031
  return 1;
3451
4032
  }
3452
4033
  if (cmd === "stable") {
@@ -3463,13 +4044,16 @@ async function main() {
3463
4044
  const sub = argv[1];
3464
4045
  if (sub === "join") return orgJoinCommand(argv[2]);
3465
4046
  console.error(`Organisation management has moved to the web: token-derby web`);
3466
- console.error(`The only CLI organisation command is: organisation join <token>`);
4047
+ console.error(`The only CLI organisation command is: organisation join [token]`);
3467
4048
  return 2;
3468
4049
  }
3469
4050
  if (cmd === "create") {
3470
4051
  const orgName = parseFlag(argv.slice(1), "--organisation");
3471
4052
  return createRaceCommand(orgName);
3472
4053
  }
4054
+ if (cmd === "logout") return logoutCommand();
4055
+ if (cmd === "link") return linkCommand(argv.slice(1));
4056
+ if (cmd === "whoami") return whoamiCommand();
3473
4057
  if (cmd === "join") return joinCommand(argv[1], argv.slice(2));
3474
4058
  if (cmd === "end") return endCommand(argv[1]);
3475
4059
  if (cmd === "roll") return rollCommand();