@mauricode/token-derby 1.0.0 → 2.0.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
@@ -250,7 +250,7 @@ function defaultColors() {
250
250
 
251
251
  // src/ui/HorseCreator.tsx
252
252
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
253
- function HorseCreator({ onSubmit, onCancel, initialColors, initialName, lockName }) {
253
+ function HorseCreator({ onSubmit, onCancel, initialColors, initialName, lockName, initialLevel }) {
254
254
  const [colors, setColors] = useState(initialColors ?? defaultColors());
255
255
  const [slotIdx, setSlotIdx] = useState(0);
256
256
  const [namingMode, setNamingMode] = useState(false);
@@ -296,6 +296,14 @@ function HorseCreator({ onSubmit, onCancel, initialColors, initialName, lockName
296
296
  onSubmit(value.trim(), colors);
297
297
  };
298
298
  return /* @__PURE__ */ jsxs(Box2, { flexDirection: "column", children: [
299
+ lockName && initialName && /* @__PURE__ */ jsxs(Box2, { marginBottom: 1, children: [
300
+ /* @__PURE__ */ jsx2(Text2, { bold: true, children: initialName }),
301
+ typeof initialLevel === "number" && /* @__PURE__ */ jsxs(Text2, { color: "cyan", children: [
302
+ " [Lvl. ",
303
+ initialLevel,
304
+ "]"
305
+ ] })
306
+ ] }),
299
307
  /* @__PURE__ */ jsx2(Box2, { marginBottom: 1, children: /* @__PURE__ */ jsx2(HorseSprite, { sprite: MAIN_SPRITE, colors }) }),
300
308
  /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: SLOTS.map((s, i) => /* @__PURE__ */ jsxs(Text2, { children: [
301
309
  i === slotIdx ? "\u25BA" : " ",
@@ -318,8 +326,72 @@ function HorseCreator({ onSubmit, onCancel, initialColors, initialName, lockName
318
326
  ] });
319
327
  }
320
328
 
321
- // src/stable/stable.ts
322
- import * as fs from "fs/promises";
329
+ // src/config.ts
330
+ var DEFAULT_API_BASE = "https://token-derby.mauricode.co.uk/api";
331
+ function apiBase() {
332
+ return process.env.TOKEN_DERBY_API_BASE ?? DEFAULT_API_BASE;
333
+ }
334
+ var HEARTBEAT_INTERVAL_MS = 6e4;
335
+ var HEARTBEAT_RETRY_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
336
+
337
+ // src/version.ts
338
+ import { createRequire } from "module";
339
+ function readVersion() {
340
+ if ("2.0.0".length > 0) {
341
+ return "2.0.0";
342
+ }
343
+ try {
344
+ const req = createRequire(import.meta.url);
345
+ const pkg = req("../package.json");
346
+ if (typeof pkg.version === "string") return pkg.version;
347
+ } catch {
348
+ }
349
+ return "0.0.0-dev";
350
+ }
351
+ var CLI_VERSION = readVersion();
352
+
353
+ // ../shared/dist/constants.js
354
+ var CLI_VERSION_HEADER = "x-cli-version";
355
+ var USER_ID_HEADER = "x-user-id";
356
+ var USER_TOKEN_HEADER = "x-user-token";
357
+ var USER_NAME_MAX_LENGTH = 40;
358
+ var ORG_NAME_MAX_LENGTH = 12;
359
+ var ORG_NAME_PATTERN = /^[A-Za-z0-9]{1,12}$/;
360
+
361
+ // ../shared/dist/levels.js
362
+ var MAX_LEVEL = 30;
363
+ function xpForLevel(n) {
364
+ return 2.5 * n ** 3 + 20 * n ** 2 + 50 * n - 22.5;
365
+ }
366
+ function thresholdForLevel(level) {
367
+ if (level <= 1)
368
+ return 0;
369
+ return Math.round(xpForLevel(level - 1));
370
+ }
371
+ var XP_THRESHOLDS = Array.from({ length: MAX_LEVEL }, (_, i) => thresholdForLevel(i + 1));
372
+ function levelFromXp(xp) {
373
+ const v = Math.max(0, Math.floor(xp));
374
+ let level = 1;
375
+ while (level < MAX_LEVEL && v >= thresholdForLevel(level + 1)) {
376
+ level++;
377
+ }
378
+ return level;
379
+ }
380
+ function levelInfo(xp) {
381
+ const v = Math.max(0, Math.floor(xp));
382
+ const level = levelFromXp(v);
383
+ const level_start_xp = thresholdForLevel(level);
384
+ const isMax = level >= MAX_LEVEL;
385
+ const next_level_xp = isMax ? null : thresholdForLevel(level + 1);
386
+ const xp_into_level = v - level_start_xp;
387
+ const xp_for_level = isMax ? null : next_level_xp - level_start_xp;
388
+ const progress = isMax ? 1 : Math.min(1, xp_into_level / Math.max(1, xp_for_level));
389
+ return { level, xp: v, level_start_xp, next_level_xp, xp_into_level, xp_for_level, progress };
390
+ }
391
+
392
+ // src/identity/identity.ts
393
+ import { promises as fs } from "fs";
394
+ import * as path2 from "path";
323
395
 
324
396
  // src/paths.ts
325
397
  import * as os from "os";
@@ -327,9 +399,6 @@ import * as path from "path";
327
399
  function homeDir() {
328
400
  return process.env.TOKEN_DERBY_HOME ?? path.join(os.homedir(), ".token-derby");
329
401
  }
330
- function stableFile() {
331
- return path.join(homeDir(), "stable.json");
332
- }
333
402
  function identityFile() {
334
403
  return path.join(homeDir(), "identity.json");
335
404
  }
@@ -343,63 +412,185 @@ function claudeProjectsDir() {
343
412
  return process.env.TOKEN_DERBY_CLAUDE_DIR ?? path.join(os.homedir(), ".claude", "projects");
344
413
  }
345
414
 
346
- // src/stable/stable.ts
347
- async function loadStable() {
415
+ // src/identity/identity.ts
416
+ async function loadIdentity() {
348
417
  try {
349
- const raw = await fs.readFile(stableFile(), "utf8");
418
+ const raw = await fs.readFile(identityFile(), "utf8");
350
419
  const parsed = JSON.parse(raw);
351
- if (!parsed || !Array.isArray(parsed.horses)) return { horses: [] };
352
- return parsed;
420
+ if (typeof parsed.user_id === "string" && typeof parsed.display_name === "string" && typeof parsed.secret_token === "string" && typeof parsed.created_at === "string") {
421
+ return parsed;
422
+ }
423
+ return null;
353
424
  } catch (e) {
354
- if (e?.code === "ENOENT") return { horses: [] };
355
- if (e instanceof SyntaxError) return { horses: [] };
356
- throw e;
425
+ if (e?.code === "ENOENT") return null;
426
+ return null;
357
427
  }
358
428
  }
359
- async function saveStable(stable) {
429
+ async function saveIdentity(identity) {
360
430
  await fs.mkdir(homeDir(), { recursive: true });
361
- await fs.writeFile(stableFile(), JSON.stringify(stable, null, 2) + "\n", "utf8");
431
+ await fs.writeFile(identityFile(), JSON.stringify(identity, null, 2) + "\n", "utf8");
432
+ }
433
+ async function deleteIdentity() {
434
+ try {
435
+ await fs.unlink(identityFile());
436
+ } catch (e) {
437
+ if (e?.code !== "ENOENT") throw e;
438
+ }
439
+ }
440
+ function validateDisplayName(name) {
441
+ const trimmed = name.trim();
442
+ if (trimmed.length < 1) return { ok: false, error: "Name cannot be empty." };
443
+ if (trimmed.length > USER_NAME_MAX_LENGTH) {
444
+ return { ok: false, error: `Name must be ${USER_NAME_MAX_LENGTH} characters or fewer.` };
445
+ }
446
+ return { ok: true, name: trimmed };
362
447
  }
363
- async function upsertHorse(horse) {
364
- const stable = await loadStable();
365
- const idx = stable.horses.findIndex((h) => h.name === horse.name);
366
- if (idx >= 0) stable.horses[idx] = horse;
367
- else stable.horses.push(horse);
368
- await saveStable(stable);
448
+
449
+ // src/api/client.ts
450
+ var ApiError = class extends Error {
451
+ constructor(code, message, status) {
452
+ super(message);
453
+ this.code = code;
454
+ this.status = status;
455
+ this.name = "ApiError";
456
+ }
457
+ code;
458
+ status;
459
+ };
460
+ var identityCache = null;
461
+ function getIdentity() {
462
+ if (!identityCache) identityCache = loadIdentity();
463
+ return identityCache;
369
464
  }
370
- async function removeHorse(name) {
371
- const stable = await loadStable();
372
- stable.horses = stable.horses.filter((h) => h.name !== name);
373
- await saveStable(stable);
465
+ function _resetIdentityCacheForTests() {
466
+ identityCache = null;
374
467
  }
375
- function findHorse(stable, name) {
376
- return stable.horses.find((h) => h.name === name);
468
+ async function request(method, path5, body, horseAuthToken, fetchImpl = fetch) {
469
+ const url = path5.startsWith("http") ? path5 : `${apiBase()}${path5}`;
470
+ const headers = {};
471
+ headers[CLI_VERSION_HEADER] = CLI_VERSION;
472
+ headers["user-agent"] = `token-derby/${CLI_VERSION}`;
473
+ const identity = await getIdentity();
474
+ if (identity) {
475
+ headers[USER_ID_HEADER] = identity.user_id;
476
+ headers[USER_TOKEN_HEADER] = identity.secret_token;
477
+ }
478
+ if (horseAuthToken) headers["authorization"] = `Bearer ${horseAuthToken}`;
479
+ if (body !== void 0) headers["content-type"] = "application/json";
480
+ let res;
481
+ try {
482
+ res = await fetchImpl(url, {
483
+ method,
484
+ headers,
485
+ body: body !== void 0 ? JSON.stringify(body) : void 0
486
+ });
487
+ } catch (e) {
488
+ throw new ApiError("NETWORK_ERROR", e?.message ?? "fetch failed", 0);
489
+ }
490
+ const text = await res.text();
491
+ const contentType = res.headers.get("content-type") ?? "";
492
+ let parsed = null;
493
+ if (contentType.includes("application/json") && text.length > 0) {
494
+ try {
495
+ parsed = JSON.parse(text);
496
+ } catch {
497
+ parsed = null;
498
+ }
499
+ }
500
+ if (!res.ok) {
501
+ if (parsed && typeof parsed.code === "string") {
502
+ throw new ApiError(parsed.code, parsed.message ?? "API error", res.status);
503
+ }
504
+ throw new ApiError("NETWORK_ERROR", `HTTP ${res.status}`, res.status);
505
+ }
506
+ return parsed;
507
+ }
508
+
509
+ // src/api/endpoints.ts
510
+ function createRace(body) {
511
+ return request("POST", "/races", body, void 0);
512
+ }
513
+ function getRace(joinCode) {
514
+ return request("GET", `/races/${encodeURIComponent(joinCode)}`, void 0, void 0);
515
+ }
516
+ function joinRace(joinCode, body) {
517
+ return request("POST", `/races/${encodeURIComponent(joinCode)}/join`, body, void 0);
518
+ }
519
+ function heartbeat(joinCode, horseId, token, body) {
520
+ return request(
521
+ "POST",
522
+ `/races/${encodeURIComponent(joinCode)}/horses/${encodeURIComponent(horseId)}/heartbeat`,
523
+ body,
524
+ token
525
+ );
526
+ }
527
+ function endRace(adminCode) {
528
+ return request("DELETE", `/races/admin/${encodeURIComponent(adminCode)}`, void 0, void 0);
529
+ }
530
+ function createOrganisation(body) {
531
+ return request("POST", "/organisations", body, void 0);
532
+ }
533
+ function joinOrganisation(body) {
534
+ return request("POST", "/organisations/join", body, void 0);
535
+ }
536
+ function listOrganisations() {
537
+ return request("GET", "/organisations", void 0, void 0);
538
+ }
539
+ function getOrganisation(name) {
540
+ return request("GET", `/organisations/${encodeURIComponent(name)}`, void 0, void 0);
541
+ }
542
+ function initJockey(body) {
543
+ return request("POST", "/jockey/init", body, void 0);
544
+ }
545
+ function updateJockey(body) {
546
+ return request("PUT", "/jockey/me", body, void 0);
547
+ }
548
+ function listStable() {
549
+ return request("GET", "/jockey/me/horses", void 0, void 0);
550
+ }
551
+ function createStableHorse(body) {
552
+ return request("POST", "/jockey/me/horses", body, void 0);
553
+ }
554
+ function updateStableHorse(stableHorseId, body) {
555
+ return request(
556
+ "PUT",
557
+ `/jockey/me/horses/${encodeURIComponent(stableHorseId)}`,
558
+ body,
559
+ void 0
560
+ );
561
+ }
562
+ function deleteStableHorse(stableHorseId) {
563
+ return request(
564
+ "DELETE",
565
+ `/jockey/me/horses/${encodeURIComponent(stableHorseId)}`,
566
+ void 0,
567
+ void 0
568
+ );
377
569
  }
378
570
 
379
571
  // src/commands/stable-create.ts
380
- import * as readline from "readline/promises";
381
- import { stdin, stdout } from "process";
382
572
  async function stableCreateCommand() {
383
573
  let exitCode = 0;
384
574
  const app = render(
385
575
  React2.createElement(HorseCreator, {
386
576
  onSubmit: async (name, colors) => {
387
- const stable = await loadStable();
388
- const existing = findHorse(stable, name);
389
- if (existing) {
577
+ try {
578
+ await createStableHorse({ name, colors });
390
579
  app.unmount();
391
- const rl = readline.createInterface({ input: stdin, output: stdout });
392
- const answer = (await rl.question(`Horse "${name}" already exists. Overwrite? [y/N] `)).trim().toLowerCase();
393
- rl.close();
394
- if (answer !== "y" && answer !== "yes") {
395
- console.log("Cancelled.");
580
+ console.log(`\u2713 Saved "${name}" to your stable.`);
581
+ } catch (e) {
582
+ app.unmount();
583
+ if (e instanceof ApiError) {
584
+ if (e.code === "STABLE_HORSE_NAME_TAKEN") {
585
+ console.error(`A horse named "${name}" already exists. Pick a different name or use \`token-derby stable edit ${name}\` to modify it.`);
586
+ } else {
587
+ console.error(`Error: ${e.code} ${e.message}`);
588
+ }
396
589
  exitCode = 1;
397
590
  return;
398
591
  }
592
+ throw e;
399
593
  }
400
- await upsertHorse({ name, colors, created_at: (/* @__PURE__ */ new Date()).toISOString() });
401
- app.unmount();
402
- console.log(`\u2713 Saved "${name}" to your stable.`);
403
594
  },
404
595
  onCancel: () => {
405
596
  app.unmount();
@@ -417,13 +608,23 @@ import React3 from "react";
417
608
  import { render as render2, Box as Box3, Text as Text3 } from "ink";
418
609
  import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
419
610
  async function stableListCommand() {
420
- const stable = await loadStable();
421
- if (stable.horses.length === 0) {
611
+ let horses;
612
+ try {
613
+ const resp = await listStable();
614
+ horses = resp.horses;
615
+ } catch (e) {
616
+ if (e instanceof ApiError) {
617
+ console.error(`Error: ${e.code} ${e.message}`);
618
+ return 1;
619
+ }
620
+ throw e;
621
+ }
622
+ if (horses.length === 0) {
422
623
  console.log("Your stable is empty. Run `token-derby stable create` to add a horse.");
423
624
  return 0;
424
625
  }
425
626
  const app = render2(
426
- React3.createElement(StableList, { horses: stable.horses })
627
+ React3.createElement(StableList, { horses })
427
628
  );
428
629
  await app.waitUntilExit();
429
630
  return 0;
@@ -442,76 +643,59 @@ function StableList({ horses }) {
442
643
  /* @__PURE__ */ jsx3(HorseSprite, { sprite: MINI_SPRITE, colors: h.colors }),
443
644
  /* @__PURE__ */ jsxs2(Text3, { children: [
444
645
  " ",
445
- h.name
646
+ h.name,
647
+ " ",
648
+ /* @__PURE__ */ jsxs2(Text3, { color: "cyan", children: [
649
+ "[Lvl. ",
650
+ levelFromXp(h.xp),
651
+ "]"
652
+ ] })
446
653
  ] })
447
- ] }, h.name))
654
+ ] }, h.stable_horse_id))
448
655
  ] });
449
656
  }
450
657
 
451
658
  // src/commands/stable-delete.ts
452
- import * as readline2 from "readline/promises";
453
- import { stdin as stdin2, stdout as stdout2 } from "process";
454
-
455
- // src/stable/active-race.ts
456
- import * as fs2 from "fs/promises";
457
- import * as path2 from "path";
458
- async function loadActiveRace(joinCode) {
459
- try {
460
- const raw = await fs2.readFile(activeRaceFile(joinCode), "utf8");
461
- return JSON.parse(raw);
462
- } catch (e) {
463
- if (e?.code === "ENOENT") return null;
464
- throw e;
465
- }
466
- }
467
- async function saveActiveRace(active) {
468
- await fs2.mkdir(activeRacesDir(), { recursive: true });
469
- await fs2.writeFile(
470
- activeRaceFile(active.join_code),
471
- JSON.stringify(active, null, 2) + "\n",
472
- "utf8"
473
- );
474
- }
475
- async function listActiveRaces() {
476
- try {
477
- const entries = await fs2.readdir(activeRacesDir());
478
- return entries.filter((f) => f.endsWith(".json")).map((f) => path2.basename(f, ".json"));
479
- } catch (e) {
480
- if (e?.code === "ENOENT") return [];
481
- throw e;
482
- }
483
- }
484
-
485
- // src/commands/stable-delete.ts
659
+ import * as readline from "readline/promises";
660
+ import { stdin, stdout } from "process";
486
661
  async function stableDeleteCommand(name) {
487
662
  if (!name) {
488
663
  console.error("Usage: token-derby stable delete <name>");
489
664
  return 2;
490
665
  }
491
- const stable = await loadStable();
492
- const horse = findHorse(stable, name);
666
+ let horses;
667
+ try {
668
+ horses = (await listStable()).horses;
669
+ } catch (e) {
670
+ if (e instanceof ApiError) {
671
+ console.error(`Error: ${e.code} ${e.message}`);
672
+ return 1;
673
+ }
674
+ throw e;
675
+ }
676
+ const horse = horses.find((h) => h.name === name);
493
677
  if (!horse) {
494
678
  console.error(`No horse named "${name}" in your stable.`);
495
679
  return 1;
496
680
  }
497
- const codes = await listActiveRaces();
498
- for (const code of codes) {
499
- const active = await loadActiveRace(code);
500
- if (active?.horse_name === name) {
501
- console.error(`"${name}" is currently running in race ${code}. Close that terminal first.`);
502
- return 1;
503
- }
504
- }
505
- const rl = readline2.createInterface({ input: stdin2, output: stdout2 });
681
+ const rl = readline.createInterface({ input: stdin, output: stdout });
506
682
  const answer = (await rl.question(`Delete "${name}" from your stable? [y/N] `)).trim().toLowerCase();
507
683
  rl.close();
508
684
  if (answer !== "y" && answer !== "yes") {
509
685
  console.log("Cancelled.");
510
686
  return 1;
511
687
  }
512
- await removeHorse(name);
513
- console.log(`\u2713 Deleted "${name}".`);
514
- return 0;
688
+ try {
689
+ await deleteStableHorse(horse.stable_horse_id);
690
+ console.log(`\u2713 Deleted "${name}".`);
691
+ return 0;
692
+ } catch (e) {
693
+ if (e instanceof ApiError) {
694
+ console.error(`Error: ${e.code} ${e.message}`);
695
+ return 1;
696
+ }
697
+ throw e;
698
+ }
515
699
  }
516
700
 
517
701
  // src/commands/stable-edit.ts
@@ -522,8 +706,9 @@ async function stableEditCommand(name) {
522
706
  console.error("Usage: token-derby stable edit <name>");
523
707
  return 2;
524
708
  }
525
- const stable = await loadStable();
526
- const existing = findHorse(stable, name);
709
+ const horses = await fetchStable();
710
+ if (!horses) return 1;
711
+ const existing = horses.find((h) => h.name === name);
527
712
  if (!existing) {
528
713
  console.error(`No horse named "${name}" in your stable.`);
529
714
  return 1;
@@ -534,10 +719,21 @@ async function stableEditCommand(name) {
534
719
  initialColors: existing.colors,
535
720
  initialName: existing.name,
536
721
  lockName: true,
722
+ initialLevel: levelFromXp(existing.xp),
537
723
  onSubmit: async (_name, colors) => {
538
- await upsertHorse({ name: existing.name, colors, created_at: existing.created_at });
539
- app.unmount();
540
- console.log(`\u2713 Updated "${existing.name}".`);
724
+ try {
725
+ await updateStableHorse(existing.stable_horse_id, { colors });
726
+ app.unmount();
727
+ console.log(`\u2713 Updated "${existing.name}".`);
728
+ } catch (e) {
729
+ app.unmount();
730
+ if (e instanceof ApiError) {
731
+ console.error(`Error: ${e.code} ${e.message}`);
732
+ exitCode = 1;
733
+ return;
734
+ }
735
+ throw e;
736
+ }
541
737
  },
542
738
  onCancel: () => {
543
739
  app.unmount();
@@ -549,157 +745,25 @@ async function stableEditCommand(name) {
549
745
  await app.waitUntilExit();
550
746
  return exitCode;
551
747
  }
552
-
553
- // src/commands/create.ts
554
- import * as readline3 from "readline/promises";
555
- import { stdin as stdin3, stdout as stdout3 } from "process";
556
-
557
- // src/config.ts
558
- var DEFAULT_API_BASE = "https://token-derby.mauricode.co.uk/api";
559
- function apiBase() {
560
- return process.env.TOKEN_DERBY_API_BASE ?? DEFAULT_API_BASE;
561
- }
562
- var HEARTBEAT_INTERVAL_MS = 6e4;
563
- var POLL_INTERVAL_MS = 3e3;
564
- var HEARTBEAT_RETRY_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
565
-
566
- // src/version.ts
567
- import { createRequire } from "module";
568
- function readVersion() {
569
- if ("1.0.0".length > 0) {
570
- return "1.0.0";
571
- }
572
- try {
573
- const req = createRequire(import.meta.url);
574
- const pkg = req("../package.json");
575
- if (typeof pkg.version === "string") return pkg.version;
576
- } catch {
577
- }
578
- return "0.0.0-dev";
579
- }
580
- var CLI_VERSION = readVersion();
581
-
582
- // ../shared/dist/constants.js
583
- var CLI_VERSION_HEADER = "x-cli-version";
584
- var USER_ID_HEADER = "x-user-id";
585
- var USER_NAME_HEADER = "x-user-name";
586
- var USER_NAME_MAX_LENGTH = 40;
587
-
588
- // src/identity/identity.ts
589
- import { promises as fs3 } from "fs";
590
- import * as path3 from "path";
591
- import * as crypto from "crypto";
592
- async function loadIdentity() {
593
- try {
594
- const raw = await fs3.readFile(identityFile(), "utf8");
595
- const parsed = JSON.parse(raw);
596
- if (typeof parsed.user_id === "string" && typeof parsed.display_name === "string" && typeof parsed.created_at === "string") {
597
- return parsed;
598
- }
599
- return null;
600
- } catch (e) {
601
- if (e?.code === "ENOENT") return null;
602
- return null;
603
- }
604
- }
605
- async function saveIdentity(identity) {
606
- await fs3.mkdir(homeDir(), { recursive: true });
607
- await fs3.writeFile(identityFile(), JSON.stringify(identity, null, 2) + "\n", "utf8");
608
- }
609
- function generateUserId() {
610
- return crypto.randomUUID();
611
- }
612
- function validateDisplayName(name) {
613
- const trimmed = name.trim();
614
- if (trimmed.length < 1) return { ok: false, error: "Name cannot be empty." };
615
- if (trimmed.length > USER_NAME_MAX_LENGTH) {
616
- return { ok: false, error: `Name must be ${USER_NAME_MAX_LENGTH} characters or fewer.` };
617
- }
618
- return { ok: true, name: trimmed };
619
- }
620
-
621
- // src/api/client.ts
622
- var ApiError = class extends Error {
623
- constructor(code, message, status) {
624
- super(message);
625
- this.code = code;
626
- this.status = status;
627
- this.name = "ApiError";
628
- }
629
- code;
630
- status;
631
- };
632
- var identityCache = null;
633
- function getIdentity() {
634
- if (!identityCache) identityCache = loadIdentity();
635
- return identityCache;
636
- }
637
- async function request(method, path5, body, authToken, fetchImpl = fetch) {
638
- const url = path5.startsWith("http") ? path5 : `${apiBase()}${path5}`;
639
- const headers = {};
640
- headers[CLI_VERSION_HEADER] = CLI_VERSION;
641
- const identity = await getIdentity();
642
- if (identity) {
643
- headers[USER_ID_HEADER] = identity.user_id;
644
- headers[USER_NAME_HEADER] = identity.display_name;
645
- }
646
- if (authToken) headers["authorization"] = `Bearer ${authToken}`;
647
- if (body !== void 0) headers["content-type"] = "application/json";
648
- let res;
748
+ async function fetchStable() {
649
749
  try {
650
- res = await fetchImpl(url, {
651
- method,
652
- headers,
653
- body: body !== void 0 ? JSON.stringify(body) : void 0
654
- });
750
+ const resp = await listStable();
751
+ return resp.horses;
655
752
  } catch (e) {
656
- throw new ApiError("NETWORK_ERROR", e?.message ?? "fetch failed", 0);
657
- }
658
- const text = await res.text();
659
- const contentType = res.headers.get("content-type") ?? "";
660
- let parsed = null;
661
- if (contentType.includes("application/json") && text.length > 0) {
662
- try {
663
- parsed = JSON.parse(text);
664
- } catch {
665
- parsed = null;
666
- }
667
- }
668
- if (!res.ok) {
669
- if (parsed && typeof parsed.code === "string") {
670
- throw new ApiError(parsed.code, parsed.message ?? "API error", res.status);
753
+ if (e instanceof ApiError) {
754
+ console.error(`Error: ${e.code} ${e.message}`);
755
+ return null;
671
756
  }
672
- throw new ApiError("NETWORK_ERROR", `HTTP ${res.status}`, res.status);
757
+ throw e;
673
758
  }
674
- return parsed;
675
- }
676
-
677
- // src/api/endpoints.ts
678
- function createRace(body) {
679
- return request("POST", "/races", body, void 0);
680
- }
681
- function getRace(joinCode) {
682
- return request("GET", `/races/${encodeURIComponent(joinCode)}`, void 0, void 0);
683
- }
684
- function joinRace(joinCode, body) {
685
- return request("POST", `/races/${encodeURIComponent(joinCode)}/join`, body, void 0);
686
- }
687
- function heartbeat(joinCode, horseId, token, body) {
688
- return request(
689
- "POST",
690
- `/races/${encodeURIComponent(joinCode)}/horses/${encodeURIComponent(horseId)}/heartbeat`,
691
- body,
692
- token
693
- );
694
- }
695
- function endRace(adminCode) {
696
- return request("DELETE", `/races/admin/${encodeURIComponent(adminCode)}`, void 0, void 0);
697
759
  }
698
760
 
699
761
  // src/commands/create.ts
762
+ import * as readline2 from "readline/promises";
763
+ import { stdin as stdin2, stdout as stdout2 } from "process";
700
764
  var DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
701
- async function createRaceCommand() {
702
- const rl = readline3.createInterface({ input: stdin3, output: stdout3 });
765
+ async function createRaceCommand(organisationName) {
766
+ const rl = readline2.createInterface({ input: stdin2, output: stdout2 });
703
767
  try {
704
768
  const name = (await rl.question("Race name: ")).trim();
705
769
  if (!name) {
@@ -726,12 +790,22 @@ async function createRaceCommand() {
726
790
  console.error("Max participants must be a positive number.");
727
791
  return 1;
728
792
  }
793
+ let org = organisationName;
794
+ if (org === void 0) {
795
+ const raw = (await rl.question("Organisation (blank for none): ")).trim();
796
+ if (raw) org = raw;
797
+ }
798
+ if (org !== void 0 && !ORG_NAME_PATTERN.test(org)) {
799
+ console.error("Organisation name must be 1\u201312 alphanumeric characters.");
800
+ return 1;
801
+ }
729
802
  const resp = await createRace({
730
803
  name,
731
804
  start_time: start,
732
805
  end_time: end,
733
806
  tz,
734
- ...max !== void 0 ? { max_participants: max } : {}
807
+ ...max !== void 0 ? { max_participants: max } : {},
808
+ ...org ? { organisation_name: org } : {}
735
809
  });
736
810
  console.log("");
737
811
  console.log(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
@@ -741,6 +815,9 @@ async function createRaceCommand() {
741
815
  console.log(` Admin code: ${resp.admin_code}`);
742
816
  console.log(" \u26A0 Save the admin code \u2014 you need it to end the race early.");
743
817
  console.log("");
818
+ if (org) {
819
+ console.log(` Restricted to organisation: ${org}`);
820
+ }
744
821
  console.log(` Share with participants: token-derby join ${resp.join_code}`);
745
822
  return 0;
746
823
  } catch (e) {
@@ -800,17 +877,44 @@ function HorsePicker({ horses, onPick, onCancel }) {
800
877
  /* @__PURE__ */ jsx4(Box4, { flexDirection: "row", children: /* @__PURE__ */ jsxs3(Text4, { children: [
801
878
  i === idx ? "\u25BA" : " ",
802
879
  " ",
803
- h.name
880
+ h.name,
881
+ " ",
882
+ /* @__PURE__ */ jsxs3(Text4, { color: "cyan", children: [
883
+ "[Lvl. ",
884
+ levelFromXp(h.xp),
885
+ "]"
886
+ ] })
804
887
  ] }) }),
805
888
  /* @__PURE__ */ jsxs3(Box4, { flexDirection: "row", children: [
806
889
  /* @__PURE__ */ jsx4(Text4, { children: " " }),
807
890
  /* @__PURE__ */ jsx4(HorseSprite, { sprite: MINI_SPRITE, colors: h.colors })
808
891
  ] })
809
- ] }, h.name)),
892
+ ] }, h.stable_horse_id)),
810
893
  /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u2191/\u2193 choose \xB7 Enter pick \xB7 Esc cancel" }) })
811
894
  ] });
812
895
  }
813
896
 
897
+ // src/stable/active-race.ts
898
+ import * as fs2 from "fs/promises";
899
+ import * as path3 from "path";
900
+ async function loadActiveRace(joinCode) {
901
+ try {
902
+ const raw = await fs2.readFile(activeRaceFile(joinCode), "utf8");
903
+ return JSON.parse(raw);
904
+ } catch (e) {
905
+ if (e?.code === "ENOENT") return null;
906
+ throw e;
907
+ }
908
+ }
909
+ async function saveActiveRace(active) {
910
+ await fs2.mkdir(activeRacesDir(), { recursive: true });
911
+ await fs2.writeFile(
912
+ activeRaceFile(active.join_code),
913
+ JSON.stringify(active, null, 2) + "\n",
914
+ "utf8"
915
+ );
916
+ }
917
+
814
918
  // src/runtime/run-race.tsx
815
919
  import { useEffect, useRef, useState as useState3 } from "react";
816
920
  import { Box as Box6, Text as Text6, useApp } from "ink";
@@ -827,6 +931,7 @@ function StatusScreen(props) {
827
931
  const leader = race.horses[0];
828
932
  const elapsedPct = elapsed(race);
829
933
  const timeLeft = formatDuration(race.time_left_seconds);
934
+ const lvl = levelInfo(own?.xp ?? 0);
830
935
  return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
831
936
  /* @__PURE__ */ jsxs4(Text5, { children: [
832
937
  "\u{1F3C7} TOKEN DERBY \u2500\u2500\u2500 ",
@@ -839,7 +944,13 @@ function StatusScreen(props) {
839
944
  /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", children: [
840
945
  /* @__PURE__ */ jsxs4(Text5, { children: [
841
946
  " ",
842
- ownHorseName
947
+ ownHorseName,
948
+ " ",
949
+ /* @__PURE__ */ jsxs4(Text5, { color: "cyan", children: [
950
+ "[Lvl. ",
951
+ lvl.level,
952
+ "]"
953
+ ] })
843
954
  ] }),
844
955
  /* @__PURE__ */ jsxs4(Text5, { children: [
845
956
  " ",
@@ -876,6 +987,10 @@ function StatusScreen(props) {
876
987
  "Time left: ",
877
988
  timeLeft
878
989
  ] }),
990
+ /* @__PURE__ */ jsxs4(Text5, { children: [
991
+ "XP: ",
992
+ lvl.next_level_xp === null ? `${lvl.xp} (max level) ${bar(1, 20)}` : `${lvl.xp_into_level}/${lvl.xp_for_level} \u2192 Lvl. ${lvl.level + 1} ${bar(lvl.progress, 20)}`
993
+ ] }),
879
994
  /* @__PURE__ */ jsxs4(Text5, { children: [
880
995
  "Last heartbeat: ",
881
996
  lastHeartbeatAgoSec === null ? "\u2014" : `${lastHeartbeatAgoSec}s ago`,
@@ -949,31 +1064,8 @@ function runHeartbeatLoop(opts) {
949
1064
  schedule(0);
950
1065
  }
951
1066
 
952
- // src/runtime/poll-loop.ts
953
- function runPollLoop(opts) {
954
- let timer = null;
955
- let stopped = false;
956
- const stop = () => {
957
- stopped = true;
958
- if (timer) clearTimeout(timer);
959
- timer = null;
960
- };
961
- opts.abortSignal.addEventListener("abort", stop, { once: true });
962
- const tick = async () => {
963
- if (stopped) return;
964
- try {
965
- const race = await opts.fetchRace();
966
- if (!stopped) opts.onSnapshot(race);
967
- } catch (err) {
968
- if (!stopped) opts.onError(err);
969
- }
970
- if (!stopped) timer = setTimeout(tick, opts.intervalMs);
971
- };
972
- timer = setTimeout(tick, 0);
973
- }
974
-
975
1067
  // src/tokens/transcripts.ts
976
- import * as fs4 from "fs/promises";
1068
+ import * as fs3 from "fs/promises";
977
1069
  import * as path4 from "path";
978
1070
  async function sumTokens() {
979
1071
  const root = claudeProjectsDir();
@@ -989,12 +1081,18 @@ async function sumTokens() {
989
1081
  }
990
1082
  async function sumOutputTokens() {
991
1083
  const { input, output } = await sumTokens();
992
- return input + output;
1084
+ return countInputTokens() ? input + output : output;
1085
+ }
1086
+ function countInputTokens() {
1087
+ const v = process.env.TOKEN_DERBY_COUNT_INPUT_TOKENS;
1088
+ if (!v) return false;
1089
+ const s = v.toLowerCase();
1090
+ return s === "1" || s === "true" || s === "yes" || s === "on";
993
1091
  }
994
1092
  async function listJsonlFiles(root) {
995
1093
  let projects;
996
1094
  try {
997
- projects = await fs4.readdir(root);
1095
+ projects = await fs3.readdir(root);
998
1096
  } catch (e) {
999
1097
  if (e?.code === "ENOENT") return [];
1000
1098
  throw e;
@@ -1004,12 +1102,12 @@ async function listJsonlFiles(root) {
1004
1102
  const projectDir = path4.join(root, project);
1005
1103
  let stat2;
1006
1104
  try {
1007
- stat2 = await fs4.stat(projectDir);
1105
+ stat2 = await fs3.stat(projectDir);
1008
1106
  } catch {
1009
1107
  continue;
1010
1108
  }
1011
1109
  if (!stat2.isDirectory()) continue;
1012
- const entries = await fs4.readdir(projectDir);
1110
+ const entries = await fs3.readdir(projectDir);
1013
1111
  for (const entry of entries) {
1014
1112
  if (entry.endsWith(".jsonl")) out.push(path4.join(projectDir, entry));
1015
1113
  }
@@ -1022,7 +1120,7 @@ function addNum(value) {
1022
1120
  async function sumFile(file) {
1023
1121
  let raw;
1024
1122
  try {
1025
- raw = await fs4.readFile(file, "utf8");
1123
+ raw = await fs3.readFile(file, "utf8");
1026
1124
  } catch {
1027
1125
  return { input: 0, output: 0 };
1028
1126
  }
@@ -1075,14 +1173,6 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1075
1173
  }
1076
1174
  }, [race?.status]);
1077
1175
  useEffect(() => {
1078
- runPollLoop({
1079
- fetchRace: () => getRace(active.join_code),
1080
- intervalMs: POLL_INTERVAL_MS,
1081
- onSnapshot: (r) => setRace(r),
1082
- onError: () => {
1083
- },
1084
- abortSignal: ctrl.current.signal
1085
- });
1086
1176
  runHeartbeatLoop({
1087
1177
  sendHeartbeat: async (currentTokens) => {
1088
1178
  const resp = await heartbeat(
@@ -1108,6 +1198,7 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1108
1198
  onSuccess: (resp) => {
1109
1199
  setLastHbAt(/* @__PURE__ */ new Date());
1110
1200
  setLastHbOk(true);
1201
+ setRace(raceViewFrom(resp));
1111
1202
  if (resp.race_status === "finished") exit();
1112
1203
  },
1113
1204
  onError: (err) => {
@@ -1158,6 +1249,15 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1158
1249
  }
1159
1250
  );
1160
1251
  }
1252
+ function raceViewFrom(resp) {
1253
+ return {
1254
+ ...resp.race,
1255
+ status: resp.race_status,
1256
+ horses: resp.horses,
1257
+ server_time: resp.server_time,
1258
+ time_left_seconds: resp.time_left_seconds
1259
+ };
1260
+ }
1161
1261
  async function buildInitialState(args) {
1162
1262
  const runningTotal = await sumOutputTokens();
1163
1263
  if (args.rejoin) {
@@ -1200,31 +1300,43 @@ async function joinCommand(joinCode) {
1200
1300
  return 1;
1201
1301
  }
1202
1302
  const ownHorse = race.horses.find((h) => h.user_id === identity.user_id) ?? null;
1303
+ let chosenStableHorseId;
1203
1304
  let chosenName;
1204
1305
  let chosenColors;
1205
1306
  let isResume;
1206
1307
  if (ownHorse) {
1308
+ chosenStableHorseId = ownHorse.stable_horse_id;
1207
1309
  chosenName = ownHorse.name;
1208
1310
  chosenColors = ownHorse.colors;
1209
1311
  isResume = true;
1210
1312
  } else {
1211
- const stable = await loadStable();
1212
- if (stable.horses.length === 0) {
1313
+ let horses;
1314
+ try {
1315
+ horses = (await listStable()).horses;
1316
+ } catch (e) {
1317
+ if (e instanceof ApiError) {
1318
+ console.error(`Error: ${e.code} ${e.message}`);
1319
+ return 1;
1320
+ }
1321
+ throw e;
1322
+ }
1323
+ if (horses.length === 0) {
1213
1324
  console.error("Your stable is empty. Run `token-derby stable create` first.");
1214
1325
  return 1;
1215
1326
  }
1216
- const picked = await pickHorse(stable.horses);
1327
+ const picked = await pickHorse(horses);
1217
1328
  if (!picked) {
1218
1329
  console.log("Cancelled.");
1219
1330
  return 1;
1220
1331
  }
1332
+ chosenStableHorseId = picked.stable_horse_id;
1221
1333
  chosenName = picked.name;
1222
1334
  chosenColors = picked.colors;
1223
1335
  isResume = false;
1224
1336
  }
1225
1337
  let joinResp;
1226
1338
  try {
1227
- joinResp = await joinRace(code, { horse: { name: chosenName, colors: chosenColors } });
1339
+ joinResp = await joinRace(code, { stable_horse_id: chosenStableHorseId });
1228
1340
  } catch (e) {
1229
1341
  if (e instanceof ApiError) {
1230
1342
  if (e.code === "RACE_FULL") console.error("This race is full.");
@@ -1232,7 +1344,9 @@ async function joinCommand(joinCode) {
1232
1344
  else if (e.code === "RACE_NOT_FOUND") console.error(`No race with join code ${code}.`);
1233
1345
  else if (e.code === "VERSION_MISMATCH") console.error(e.message);
1234
1346
  else if (e.code === "DUPLICATE_HORSE") console.error(e.message);
1235
- else if (e.code === "IDENTITY_REQUIRED") console.error(`Error: ${e.message}`);
1347
+ else if (e.code === "STABLE_HORSE_NOT_FOUND") {
1348
+ console.error("That horse no longer exists in your stable. Try again.");
1349
+ } else if (e.code === "NOT_ORG_MEMBER") console.error(e.message);
1236
1350
  else console.error(`Error: ${e.code} ${e.message}`);
1237
1351
  return 1;
1238
1352
  }
@@ -1277,14 +1391,14 @@ async function pickHorse(horses) {
1277
1391
  }
1278
1392
 
1279
1393
  // src/commands/end.ts
1280
- import * as readline4 from "readline/promises";
1281
- import { stdin as stdin4, stdout as stdout4 } from "process";
1394
+ import * as readline3 from "readline/promises";
1395
+ import { stdin as stdin3, stdout as stdout3 } from "process";
1282
1396
  async function endCommand(adminCode) {
1283
1397
  if (!adminCode) {
1284
1398
  console.error("Usage: token-derby end <admin-code>");
1285
1399
  return 2;
1286
1400
  }
1287
- const rl = readline4.createInterface({ input: stdin4, output: stdout4 });
1401
+ const rl = readline3.createInterface({ input: stdin3, output: stdout3 });
1288
1402
  const answer = (await rl.question("End the race now and freeze final tokens? [y/N] ")).trim().toLowerCase();
1289
1403
  rl.close();
1290
1404
  if (answer !== "y" && answer !== "yes") {
@@ -1306,11 +1420,16 @@ async function endCommand(adminCode) {
1306
1420
  }
1307
1421
 
1308
1422
  // src/commands/init.ts
1309
- import * as readline5 from "readline/promises";
1310
- import { stdin as stdin5, stdout as stdout5 } from "process";
1311
- async function initCommand() {
1423
+ import * as readline4 from "readline/promises";
1424
+ import { stdin as stdin4, stdout as stdout4 } from "process";
1425
+ async function initCommand(reset = false) {
1426
+ if (reset) {
1427
+ await deleteIdentity();
1428
+ _resetIdentityCacheForTests();
1429
+ console.log("Removed local identity. Creating a new one\u2026");
1430
+ }
1312
1431
  const existing = await loadIdentity();
1313
- const rl = readline5.createInterface({ input: stdin5, output: stdout5 });
1432
+ const rl = readline4.createInterface({ input: stdin4, output: stdout4 });
1314
1433
  try {
1315
1434
  if (existing) {
1316
1435
  console.log(`Current jockey name: ${existing.display_name}`);
@@ -1324,10 +1443,25 @@ async function initCommand() {
1324
1443
  console.error(v2.error);
1325
1444
  return 1;
1326
1445
  }
1327
- const updated = { ...existing, display_name: v2.name };
1328
- await saveIdentity(updated);
1329
- console.log(`Updated jockey name to: ${updated.display_name}`);
1330
- return 0;
1446
+ try {
1447
+ const resp = await updateJockey({ display_name: v2.name });
1448
+ const updated = { ...existing, display_name: resp.display_name };
1449
+ await saveIdentity(updated);
1450
+ console.log(`Updated jockey name to: ${updated.display_name}`);
1451
+ return 0;
1452
+ } catch (e) {
1453
+ if (e instanceof ApiError) {
1454
+ if (e.code === "UNAUTHENTICATED") {
1455
+ console.error(
1456
+ "Server does not recognise this identity. Your account may have been wiped. Run `token-derby init --reset` to start fresh."
1457
+ );
1458
+ } else {
1459
+ console.error(`Error: ${e.code} ${e.message}`);
1460
+ }
1461
+ return 1;
1462
+ }
1463
+ throw e;
1464
+ }
1331
1465
  }
1332
1466
  const raw = (await rl.question("Jockey Name (use your real name please): ")).trim();
1333
1467
  const v = validateDisplayName(raw);
@@ -1335,26 +1469,152 @@ async function initCommand() {
1335
1469
  console.error(v.error);
1336
1470
  return 1;
1337
1471
  }
1338
- const identity = {
1339
- user_id: generateUserId(),
1340
- display_name: v.name,
1341
- created_at: (/* @__PURE__ */ new Date()).toISOString()
1342
- };
1343
- await saveIdentity(identity);
1472
+ try {
1473
+ const resp = await initJockey({ display_name: v.name });
1474
+ const identity = {
1475
+ user_id: resp.user_id,
1476
+ display_name: resp.display_name,
1477
+ secret_token: resp.secret_token,
1478
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
1479
+ };
1480
+ await saveIdentity(identity);
1481
+ _resetIdentityCacheForTests();
1482
+ console.log("");
1483
+ console.log(`Welcome, ${identity.display_name}!`);
1484
+ console.log("Your identity has been created on the server.");
1485
+ console.log("You can now create a stable and join races.");
1486
+ console.log("");
1487
+ console.log(" \u26A0 Your secret token is stored locally in identity.json.");
1488
+ console.log(" If you lose it, you cannot recover this account \u2014 you would");
1489
+ console.log(" need to run `token-derby init --reset` and rebuild your stable.");
1490
+ return 0;
1491
+ } catch (e) {
1492
+ if (e instanceof ApiError) {
1493
+ console.error(`Error: ${e.code} ${e.message}`);
1494
+ return 1;
1495
+ }
1496
+ throw e;
1497
+ }
1498
+ } finally {
1499
+ rl.close();
1500
+ }
1501
+ }
1502
+
1503
+ // src/commands/org-create.ts
1504
+ import * as readline5 from "readline/promises";
1505
+ import { stdin as stdin5, stdout as stdout5 } from "process";
1506
+ async function orgCreateCommand() {
1507
+ const rl = readline5.createInterface({ input: stdin5, output: stdout5 });
1508
+ try {
1509
+ const name = (await rl.question(`Organisation name (1\u2013${ORG_NAME_MAX_LENGTH} alphanumeric chars): `)).trim();
1510
+ if (!ORG_NAME_PATTERN.test(name)) {
1511
+ console.error(`Name must be 1\u2013${ORG_NAME_MAX_LENGTH} alphanumeric characters (no spaces or symbols).`);
1512
+ return 1;
1513
+ }
1514
+ const resp = await createOrganisation({ name });
1515
+ console.log("");
1516
+ console.log(` Organisation created: ${resp.org_name}`);
1517
+ console.log(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
1518
+ console.log(` \u2551 JOIN TOKEN: ${resp.org_join_token.padEnd(43)}\u2551`);
1519
+ console.log(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D");
1520
+ console.log(" \u26A0 Share this token to invite members. Treat it as a secret.");
1344
1521
  console.log("");
1345
- console.log(`Welcome, ${identity.display_name}!`);
1346
- console.log(`Your identity is saved. You can now create a stable and join races.`);
1522
+ console.log(` Members join with: token-derby organisation join ${resp.org_join_token}`);
1523
+ console.log(` Create org races: token-derby create --organisation ${resp.org_name}`);
1347
1524
  return 0;
1525
+ } catch (e) {
1526
+ if (e instanceof ApiError) {
1527
+ console.error(`Error: ${e.code} ${e.message}`);
1528
+ return 1;
1529
+ }
1530
+ throw e;
1348
1531
  } finally {
1349
1532
  rl.close();
1350
1533
  }
1351
1534
  }
1352
1535
 
1536
+ // src/commands/org-join.ts
1537
+ async function orgJoinCommand(token) {
1538
+ if (!token) {
1539
+ console.error("Usage: token-derby organisation join <join-token>");
1540
+ return 2;
1541
+ }
1542
+ try {
1543
+ const resp = await joinOrganisation({ join_token: token });
1544
+ console.log(`Joined organisation: ${resp.org_name}`);
1545
+ return 0;
1546
+ } catch (e) {
1547
+ if (e instanceof ApiError) {
1548
+ console.error(`Error: ${e.code} ${e.message}`);
1549
+ return 1;
1550
+ }
1551
+ throw e;
1552
+ }
1553
+ }
1554
+
1555
+ // src/commands/org-list.ts
1556
+ async function orgListCommand() {
1557
+ try {
1558
+ const resp = await listOrganisations();
1559
+ if (resp.organisations.length === 0) {
1560
+ console.log("You are not in any organisations.");
1561
+ console.log("Create one with: token-derby organisation create");
1562
+ console.log("Or join one with: token-derby organisation join <token>");
1563
+ return 0;
1564
+ }
1565
+ console.log(`Your organisations (${resp.organisations.length}):`);
1566
+ for (const o of resp.organisations) {
1567
+ console.log(` \u2022 ${o.org_name}`);
1568
+ }
1569
+ return 0;
1570
+ } catch (e) {
1571
+ if (e instanceof ApiError) {
1572
+ console.error(`Error: ${e.code} ${e.message}`);
1573
+ return 1;
1574
+ }
1575
+ throw e;
1576
+ }
1577
+ }
1578
+
1579
+ // src/commands/org-info.ts
1580
+ async function orgInfoCommand(name) {
1581
+ if (!name) {
1582
+ console.error("Usage: token-derby organisation info <name>");
1583
+ return 2;
1584
+ }
1585
+ if (!ORG_NAME_PATTERN.test(name)) {
1586
+ console.error("Organisation name must be 1\u201312 alphanumeric characters.");
1587
+ return 2;
1588
+ }
1589
+ try {
1590
+ const resp = await getOrganisation(name);
1591
+ console.log(`Organisation: ${resp.org_name}`);
1592
+ console.log(`Created: ${resp.created_at} by ${resp.creator_user_name}`);
1593
+ console.log("");
1594
+ console.log(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
1595
+ console.log(` \u2551 JOIN TOKEN: ${resp.org_join_token.padEnd(43)}\u2551`);
1596
+ console.log(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D");
1597
+ console.log(" \u26A0 Treat the token as a secret \u2014 anyone with it can join.");
1598
+ console.log("");
1599
+ console.log(` Members join with: token-derby organisation join ${resp.org_join_token}`);
1600
+ return 0;
1601
+ } catch (e) {
1602
+ if (e instanceof ApiError) {
1603
+ console.error(`Error: ${e.code} ${e.message}`);
1604
+ return 1;
1605
+ }
1606
+ throw e;
1607
+ }
1608
+ }
1609
+
1353
1610
  // src/bin.ts
1354
1611
  var HELP = `token-derby v${CLI_VERSION}
1355
1612
 
1356
1613
  Identity:
1357
1614
  token-derby init Set up your jockey identity (run this first)
1615
+ Re-running renames you on the server.
1616
+ token-derby init --reset Wipe local identity and create a fresh account.
1617
+ Your previous stable is abandoned on the server.
1358
1618
 
1359
1619
  Stable management:
1360
1620
  token-derby stable create Make a new horse (interactive)
@@ -1362,8 +1622,17 @@ Stable management:
1362
1622
  token-derby stable edit <name> Edit an existing horse's colors
1363
1623
  token-derby stable delete <name> Remove a horse from your stable
1364
1624
 
1625
+ Organisations:
1626
+ token-derby organisation create Create a new organisation (interactive)
1627
+ token-derby organisation join <token> Join an organisation with a join token
1628
+ token-derby organisation info <name> Show an org's join token (members only)
1629
+ token-derby organisation list Show organisations you're a member of
1630
+
1365
1631
  Races:
1366
- token-derby create Create a new race (interactive)
1632
+ token-derby create [--organisation <name>]
1633
+ Create a new race (interactive). When
1634
+ --organisation is set, only members of
1635
+ that org can join.
1367
1636
  token-derby join <join-code> Join (or resume) a race
1368
1637
  token-derby end <admin-code> End a race early
1369
1638
 
@@ -1382,7 +1651,10 @@ async function main() {
1382
1651
  console.log(CLI_VERSION);
1383
1652
  return 0;
1384
1653
  }
1385
- if (cmd === "init") return initCommand();
1654
+ if (cmd === "init") {
1655
+ const reset = argv.slice(1).includes("--reset");
1656
+ return initCommand(reset);
1657
+ }
1386
1658
  const identity = await loadIdentity();
1387
1659
  if (!identity) {
1388
1660
  console.error("Run `token-derby init` to set up your identity before using any other command.");
@@ -1398,13 +1670,34 @@ async function main() {
1398
1670
  console.error("Try: stable create | stable list | stable edit <name> | stable delete <name>");
1399
1671
  return 2;
1400
1672
  }
1401
- if (cmd === "create") return createRaceCommand();
1673
+ if (cmd === "organisation" || cmd === "org") {
1674
+ const sub = argv[1];
1675
+ if (sub === "create") return orgCreateCommand();
1676
+ if (sub === "join") return orgJoinCommand(argv[2]);
1677
+ if (sub === "info") return orgInfoCommand(argv[2]);
1678
+ if (sub === "list") return orgListCommand();
1679
+ console.error(`Unknown organisation subcommand: ${sub ?? "(none)"}`);
1680
+ console.error("Try: organisation create | organisation join <token> | organisation info <name> | organisation list");
1681
+ return 2;
1682
+ }
1683
+ if (cmd === "create") {
1684
+ const orgName = parseFlag(argv.slice(1), "--organisation");
1685
+ return createRaceCommand(orgName);
1686
+ }
1402
1687
  if (cmd === "join") return joinCommand(argv[1]);
1403
1688
  if (cmd === "end") return endCommand(argv[1]);
1404
1689
  console.error(`Unknown command: ${cmd}`);
1405
1690
  console.error(HELP);
1406
1691
  return 2;
1407
1692
  }
1693
+ function parseFlag(args, flag) {
1694
+ for (let i = 0; i < args.length; i++) {
1695
+ if (args[i] === flag) return args[i + 1];
1696
+ const eq = `${flag}=`;
1697
+ if (args[i]?.startsWith(eq)) return args[i].slice(eq.length);
1698
+ }
1699
+ return void 0;
1700
+ }
1408
1701
  main().then(
1409
1702
  (code) => process.exit(code),
1410
1703
  (err) => {