@mauricode/token-derby 1.1.0 → 2.0.1

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,9 +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";
323
- import { randomUUID } from "crypto";
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.1".length > 0) {
341
+ return "2.0.1";
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";
324
395
 
325
396
  // src/paths.ts
326
397
  import * as os from "os";
@@ -328,9 +399,6 @@ import * as path from "path";
328
399
  function homeDir() {
329
400
  return process.env.TOKEN_DERBY_HOME ?? path.join(os.homedir(), ".token-derby");
330
401
  }
331
- function stableFile() {
332
- return path.join(homeDir(), "stable.json");
333
- }
334
402
  function identityFile() {
335
403
  return path.join(homeDir(), "identity.json");
336
404
  }
@@ -344,78 +412,185 @@ function claudeProjectsDir() {
344
412
  return process.env.TOKEN_DERBY_CLAUDE_DIR ?? path.join(os.homedir(), ".claude", "projects");
345
413
  }
346
414
 
347
- // src/stable/stable.ts
348
- function newStableHorseId() {
349
- return randomUUID();
350
- }
351
- async function loadStable() {
415
+ // src/identity/identity.ts
416
+ async function loadIdentity() {
352
417
  try {
353
- const raw = await fs.readFile(stableFile(), "utf8");
418
+ const raw = await fs.readFile(identityFile(), "utf8");
354
419
  const parsed = JSON.parse(raw);
355
- if (!parsed || !Array.isArray(parsed.horses)) return { horses: [] };
356
- const stable = parsed;
357
- let mutated = false;
358
- const horses = stable.horses.map((h) => {
359
- if (typeof h.stable_horse_id === "string" && h.stable_horse_id.length > 0) {
360
- return h;
361
- }
362
- mutated = true;
363
- return { ...h, stable_horse_id: newStableHorseId() };
364
- });
365
- const result = { horses };
366
- if (mutated) await saveStable(result);
367
- return result;
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;
368
424
  } catch (e) {
369
- if (e?.code === "ENOENT") return { horses: [] };
370
- if (e instanceof SyntaxError) return { horses: [] };
371
- throw e;
425
+ if (e?.code === "ENOENT") return null;
426
+ return null;
372
427
  }
373
428
  }
374
- async function saveStable(stable) {
429
+ async function saveIdentity(identity) {
375
430
  await fs.mkdir(homeDir(), { recursive: true });
376
- await fs.writeFile(stableFile(), JSON.stringify(stable, null, 2) + "\n", "utf8");
431
+ await fs.writeFile(identityFile(), JSON.stringify(identity, null, 2) + "\n", "utf8");
377
432
  }
378
- async function upsertHorse(horse) {
379
- const stable = await loadStable();
380
- const idx = stable.horses.findIndex((h) => h.name === horse.name);
381
- if (idx >= 0) stable.horses[idx] = horse;
382
- else stable.horses.push(horse);
383
- await saveStable(stable);
433
+ async function deleteIdentity() {
434
+ try {
435
+ await fs.unlink(identityFile());
436
+ } catch (e) {
437
+ if (e?.code !== "ENOENT") throw e;
438
+ }
384
439
  }
385
- async function removeHorse(name) {
386
- const stable = await loadStable();
387
- stable.horses = stable.horses.filter((h) => h.name !== name);
388
- await saveStable(stable);
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 };
389
447
  }
390
- function findHorse(stable, name) {
391
- return stable.horses.find((h) => h.name === name);
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;
464
+ }
465
+ function _resetIdentityCacheForTests() {
466
+ identityCache = null;
467
+ }
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
+ );
392
569
  }
393
570
 
394
571
  // src/commands/stable-create.ts
395
- import * as readline from "readline/promises";
396
- import { stdin, stdout } from "process";
397
572
  async function stableCreateCommand() {
398
573
  let exitCode = 0;
399
574
  const app = render(
400
575
  React2.createElement(HorseCreator, {
401
576
  onSubmit: async (name, colors) => {
402
- const stable = await loadStable();
403
- const existing = findHorse(stable, name);
404
- if (existing) {
577
+ try {
578
+ await createStableHorse({ name, colors });
405
579
  app.unmount();
406
- const rl = readline.createInterface({ input: stdin, output: stdout });
407
- const answer = (await rl.question(`Horse "${name}" already exists. Overwrite? [y/N] `)).trim().toLowerCase();
408
- rl.close();
409
- if (answer !== "y" && answer !== "yes") {
410
- 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
+ }
411
589
  exitCode = 1;
412
590
  return;
413
591
  }
592
+ throw e;
414
593
  }
415
- const stable_horse_id = existing?.stable_horse_id ?? newStableHorseId();
416
- await upsertHorse({ stable_horse_id, name, colors, created_at: (/* @__PURE__ */ new Date()).toISOString() });
417
- app.unmount();
418
- console.log(`\u2713 Saved "${name}" to your stable.`);
419
594
  },
420
595
  onCancel: () => {
421
596
  app.unmount();
@@ -433,13 +608,23 @@ import React3 from "react";
433
608
  import { render as render2, Box as Box3, Text as Text3 } from "ink";
434
609
  import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
435
610
  async function stableListCommand() {
436
- const stable = await loadStable();
437
- 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) {
438
623
  console.log("Your stable is empty. Run `token-derby stable create` to add a horse.");
439
624
  return 0;
440
625
  }
441
626
  const app = render2(
442
- React3.createElement(StableList, { horses: stable.horses })
627
+ React3.createElement(StableList, { horses })
443
628
  );
444
629
  await app.waitUntilExit();
445
630
  return 0;
@@ -458,269 +643,127 @@ function StableList({ horses }) {
458
643
  /* @__PURE__ */ jsx3(HorseSprite, { sprite: MINI_SPRITE, colors: h.colors }),
459
644
  /* @__PURE__ */ jsxs2(Text3, { children: [
460
645
  " ",
461
- h.name
646
+ h.name,
647
+ " ",
648
+ /* @__PURE__ */ jsxs2(Text3, { color: "cyan", children: [
649
+ "[Lvl. ",
650
+ levelFromXp(h.xp),
651
+ "]"
652
+ ] })
462
653
  ] })
463
- ] }, h.name))
654
+ ] }, h.stable_horse_id))
464
655
  ] });
465
656
  }
466
657
 
467
658
  // src/commands/stable-delete.ts
468
- import * as readline2 from "readline/promises";
469
- import { stdin as stdin2, stdout as stdout2 } from "process";
470
-
471
- // src/stable/active-race.ts
472
- import * as fs2 from "fs/promises";
473
- import * as path2 from "path";
474
- async function loadActiveRace(joinCode) {
475
- try {
476
- const raw = await fs2.readFile(activeRaceFile(joinCode), "utf8");
477
- return JSON.parse(raw);
478
- } catch (e) {
479
- if (e?.code === "ENOENT") return null;
480
- throw e;
481
- }
482
- }
483
- async function saveActiveRace(active) {
484
- await fs2.mkdir(activeRacesDir(), { recursive: true });
485
- await fs2.writeFile(
486
- activeRaceFile(active.join_code),
487
- JSON.stringify(active, null, 2) + "\n",
488
- "utf8"
489
- );
490
- }
491
- async function listActiveRaces() {
492
- try {
493
- const entries = await fs2.readdir(activeRacesDir());
494
- return entries.filter((f) => f.endsWith(".json")).map((f) => path2.basename(f, ".json"));
495
- } catch (e) {
496
- if (e?.code === "ENOENT") return [];
497
- throw e;
498
- }
499
- }
500
-
501
- // src/commands/stable-delete.ts
659
+ import * as readline from "readline/promises";
660
+ import { stdin, stdout } from "process";
502
661
  async function stableDeleteCommand(name) {
503
662
  if (!name) {
504
663
  console.error("Usage: token-derby stable delete <name>");
505
664
  return 2;
506
665
  }
507
- const stable = await loadStable();
508
- const horse = findHorse(stable, name);
509
- if (!horse) {
510
- console.error(`No horse named "${name}" in your stable.`);
511
- return 1;
512
- }
513
- const codes = await listActiveRaces();
514
- for (const code of codes) {
515
- const active = await loadActiveRace(code);
516
- if (active?.horse_name === name) {
517
- console.error(`"${name}" is currently running in race ${code}. Close that terminal first.`);
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}`);
518
672
  return 1;
519
673
  }
674
+ throw e;
520
675
  }
521
- const rl = readline2.createInterface({ input: stdin2, output: stdout2 });
522
- const answer = (await rl.question(`Delete "${name}" from your stable? [y/N] `)).trim().toLowerCase();
523
- rl.close();
524
- if (answer !== "y" && answer !== "yes") {
525
- console.log("Cancelled.");
526
- return 1;
527
- }
528
- await removeHorse(name);
529
- console.log(`\u2713 Deleted "${name}".`);
530
- return 0;
531
- }
532
-
533
- // src/commands/stable-edit.ts
534
- import React4 from "react";
535
- import { render as render3 } from "ink";
536
- async function stableEditCommand(name) {
537
- if (!name) {
538
- console.error("Usage: token-derby stable edit <name>");
539
- return 2;
540
- }
541
- const stable = await loadStable();
542
- const existing = findHorse(stable, name);
543
- if (!existing) {
676
+ const horse = horses.find((h) => h.name === name);
677
+ if (!horse) {
544
678
  console.error(`No horse named "${name}" in your stable.`);
545
679
  return 1;
546
680
  }
547
- let exitCode = 0;
548
- const app = render3(
549
- React4.createElement(HorseCreator, {
550
- initialColors: existing.colors,
551
- initialName: existing.name,
552
- lockName: true,
553
- onSubmit: async (_name, colors) => {
554
- await upsertHorse({
555
- stable_horse_id: existing.stable_horse_id,
556
- name: existing.name,
557
- colors,
558
- created_at: existing.created_at
559
- });
560
- app.unmount();
561
- console.log(`\u2713 Updated "${existing.name}".`);
562
- },
563
- onCancel: () => {
564
- app.unmount();
565
- console.log("Cancelled.");
566
- exitCode = 1;
567
- }
568
- })
569
- );
570
- await app.waitUntilExit();
571
- return exitCode;
572
- }
573
-
574
- // src/commands/create.ts
575
- import * as readline3 from "readline/promises";
576
- import { stdin as stdin3, stdout as stdout3 } from "process";
577
-
578
- // src/config.ts
579
- var DEFAULT_API_BASE = "https://token-derby.mauricode.co.uk/api";
580
- function apiBase() {
581
- return process.env.TOKEN_DERBY_API_BASE ?? DEFAULT_API_BASE;
582
- }
583
- var HEARTBEAT_INTERVAL_MS = 6e4;
584
- var POLL_INTERVAL_MS = 3e3;
585
- var HEARTBEAT_RETRY_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
586
-
587
- // src/version.ts
588
- import { createRequire } from "module";
589
- function readVersion() {
590
- if ("1.1.0".length > 0) {
591
- return "1.1.0";
592
- }
593
- try {
594
- const req = createRequire(import.meta.url);
595
- const pkg = req("../package.json");
596
- if (typeof pkg.version === "string") return pkg.version;
597
- } catch {
598
- }
599
- return "0.0.0-dev";
600
- }
601
- var CLI_VERSION = readVersion();
602
-
603
- // ../shared/dist/constants.js
604
- var CLI_VERSION_HEADER = "x-cli-version";
605
- var USER_ID_HEADER = "x-user-id";
606
- var USER_NAME_HEADER = "x-user-name";
607
- var USER_NAME_MAX_LENGTH = 40;
608
-
609
- // src/identity/identity.ts
610
- import { promises as fs3 } from "fs";
611
- import * as path3 from "path";
612
- import * as crypto from "crypto";
613
- async function loadIdentity() {
614
- try {
615
- const raw = await fs3.readFile(identityFile(), "utf8");
616
- const parsed = JSON.parse(raw);
617
- if (typeof parsed.user_id === "string" && typeof parsed.display_name === "string" && typeof parsed.created_at === "string") {
618
- return parsed;
619
- }
620
- return null;
621
- } catch (e) {
622
- if (e?.code === "ENOENT") return null;
623
- return null;
624
- }
625
- }
626
- async function saveIdentity(identity) {
627
- await fs3.mkdir(homeDir(), { recursive: true });
628
- await fs3.writeFile(identityFile(), JSON.stringify(identity, null, 2) + "\n", "utf8");
629
- }
630
- function generateUserId() {
631
- return crypto.randomUUID();
632
- }
633
- function validateDisplayName(name) {
634
- const trimmed = name.trim();
635
- if (trimmed.length < 1) return { ok: false, error: "Name cannot be empty." };
636
- if (trimmed.length > USER_NAME_MAX_LENGTH) {
637
- return { ok: false, error: `Name must be ${USER_NAME_MAX_LENGTH} characters or fewer.` };
638
- }
639
- return { ok: true, name: trimmed };
640
- }
641
-
642
- // src/api/client.ts
643
- var ApiError = class extends Error {
644
- constructor(code, message, status) {
645
- super(message);
646
- this.code = code;
647
- this.status = status;
648
- this.name = "ApiError";
649
- }
650
- code;
651
- status;
652
- };
653
- var identityCache = null;
654
- function getIdentity() {
655
- if (!identityCache) identityCache = loadIdentity();
656
- return identityCache;
657
- }
658
- async function request(method, path5, body, authToken, fetchImpl = fetch) {
659
- const url = path5.startsWith("http") ? path5 : `${apiBase()}${path5}`;
660
- const headers = {};
661
- headers[CLI_VERSION_HEADER] = CLI_VERSION;
662
- const identity = await getIdentity();
663
- if (identity) {
664
- headers[USER_ID_HEADER] = identity.user_id;
665
- headers[USER_NAME_HEADER] = identity.display_name;
681
+ const rl = readline.createInterface({ input: stdin, output: stdout });
682
+ const answer = (await rl.question(`Delete "${name}" from your stable? [y/N] `)).trim().toLowerCase();
683
+ rl.close();
684
+ if (answer !== "y" && answer !== "yes") {
685
+ console.log("Cancelled.");
686
+ return 1;
666
687
  }
667
- if (authToken) headers["authorization"] = `Bearer ${authToken}`;
668
- if (body !== void 0) headers["content-type"] = "application/json";
669
- let res;
670
688
  try {
671
- res = await fetchImpl(url, {
672
- method,
673
- headers,
674
- body: body !== void 0 ? JSON.stringify(body) : void 0
675
- });
689
+ await deleteStableHorse(horse.stable_horse_id);
690
+ console.log(`\u2713 Deleted "${name}".`);
691
+ return 0;
676
692
  } catch (e) {
677
- throw new ApiError("NETWORK_ERROR", e?.message ?? "fetch failed", 0);
678
- }
679
- const text = await res.text();
680
- const contentType = res.headers.get("content-type") ?? "";
681
- let parsed = null;
682
- if (contentType.includes("application/json") && text.length > 0) {
683
- try {
684
- parsed = JSON.parse(text);
685
- } catch {
686
- parsed = null;
687
- }
688
- }
689
- if (!res.ok) {
690
- if (parsed && typeof parsed.code === "string") {
691
- throw new ApiError(parsed.code, parsed.message ?? "API error", res.status);
693
+ if (e instanceof ApiError) {
694
+ console.error(`Error: ${e.code} ${e.message}`);
695
+ return 1;
692
696
  }
693
- throw new ApiError("NETWORK_ERROR", `HTTP ${res.status}`, res.status);
697
+ throw e;
694
698
  }
695
- return parsed;
696
699
  }
697
700
 
698
- // src/api/endpoints.ts
699
- function createRace(body) {
700
- return request("POST", "/races", body, void 0);
701
- }
702
- function getRace(joinCode) {
703
- return request("GET", `/races/${encodeURIComponent(joinCode)}`, void 0, void 0);
704
- }
705
- function joinRace(joinCode, body) {
706
- return request("POST", `/races/${encodeURIComponent(joinCode)}/join`, body, void 0);
707
- }
708
- function heartbeat(joinCode, horseId, token, body) {
709
- return request(
710
- "POST",
711
- `/races/${encodeURIComponent(joinCode)}/horses/${encodeURIComponent(horseId)}/heartbeat`,
712
- body,
713
- token
701
+ // src/commands/stable-edit.ts
702
+ import React4 from "react";
703
+ import { render as render3 } from "ink";
704
+ async function stableEditCommand(name) {
705
+ if (!name) {
706
+ console.error("Usage: token-derby stable edit <name>");
707
+ return 2;
708
+ }
709
+ const horses = await fetchStable();
710
+ if (!horses) return 1;
711
+ const existing = horses.find((h) => h.name === name);
712
+ if (!existing) {
713
+ console.error(`No horse named "${name}" in your stable.`);
714
+ return 1;
715
+ }
716
+ let exitCode = 0;
717
+ const app = render3(
718
+ React4.createElement(HorseCreator, {
719
+ initialColors: existing.colors,
720
+ initialName: existing.name,
721
+ lockName: true,
722
+ initialLevel: levelFromXp(existing.xp),
723
+ onSubmit: async (_name, colors) => {
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
+ }
737
+ },
738
+ onCancel: () => {
739
+ app.unmount();
740
+ console.log("Cancelled.");
741
+ exitCode = 1;
742
+ }
743
+ })
714
744
  );
745
+ await app.waitUntilExit();
746
+ return exitCode;
715
747
  }
716
- function endRace(adminCode) {
717
- return request("DELETE", `/races/admin/${encodeURIComponent(adminCode)}`, void 0, void 0);
748
+ async function fetchStable() {
749
+ try {
750
+ const resp = await listStable();
751
+ return resp.horses;
752
+ } catch (e) {
753
+ if (e instanceof ApiError) {
754
+ console.error(`Error: ${e.code} ${e.message}`);
755
+ return null;
756
+ }
757
+ throw e;
758
+ }
718
759
  }
719
760
 
720
761
  // src/commands/create.ts
762
+ import * as readline2 from "readline/promises";
763
+ import { stdin as stdin2, stdout as stdout2 } from "process";
721
764
  var DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
722
- async function createRaceCommand() {
723
- const rl = readline3.createInterface({ input: stdin3, output: stdout3 });
765
+ async function createRaceCommand(organisationName) {
766
+ const rl = readline2.createInterface({ input: stdin2, output: stdout2 });
724
767
  try {
725
768
  const name = (await rl.question("Race name: ")).trim();
726
769
  if (!name) {
@@ -747,12 +790,22 @@ async function createRaceCommand() {
747
790
  console.error("Max participants must be a positive number.");
748
791
  return 1;
749
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
+ }
750
802
  const resp = await createRace({
751
803
  name,
752
804
  start_time: start,
753
805
  end_time: end,
754
806
  tz,
755
- ...max !== void 0 ? { max_participants: max } : {}
807
+ ...max !== void 0 ? { max_participants: max } : {},
808
+ ...org ? { organisation_name: org } : {}
756
809
  });
757
810
  console.log("");
758
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");
@@ -762,6 +815,9 @@ async function createRaceCommand() {
762
815
  console.log(` Admin code: ${resp.admin_code}`);
763
816
  console.log(" \u26A0 Save the admin code \u2014 you need it to end the race early.");
764
817
  console.log("");
818
+ if (org) {
819
+ console.log(` Restricted to organisation: ${org}`);
820
+ }
765
821
  console.log(` Share with participants: token-derby join ${resp.join_code}`);
766
822
  return 0;
767
823
  } catch (e) {
@@ -821,17 +877,44 @@ function HorsePicker({ horses, onPick, onCancel }) {
821
877
  /* @__PURE__ */ jsx4(Box4, { flexDirection: "row", children: /* @__PURE__ */ jsxs3(Text4, { children: [
822
878
  i === idx ? "\u25BA" : " ",
823
879
  " ",
824
- h.name
880
+ h.name,
881
+ " ",
882
+ /* @__PURE__ */ jsxs3(Text4, { color: "cyan", children: [
883
+ "[Lvl. ",
884
+ levelFromXp(h.xp),
885
+ "]"
886
+ ] })
825
887
  ] }) }),
826
888
  /* @__PURE__ */ jsxs3(Box4, { flexDirection: "row", children: [
827
889
  /* @__PURE__ */ jsx4(Text4, { children: " " }),
828
890
  /* @__PURE__ */ jsx4(HorseSprite, { sprite: MINI_SPRITE, colors: h.colors })
829
891
  ] })
830
- ] }, h.name)),
892
+ ] }, h.stable_horse_id)),
831
893
  /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u2191/\u2193 choose \xB7 Enter pick \xB7 Esc cancel" }) })
832
894
  ] });
833
895
  }
834
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
+
835
918
  // src/runtime/run-race.tsx
836
919
  import { useEffect, useRef, useState as useState3 } from "react";
837
920
  import { Box as Box6, Text as Text6, useApp } from "ink";
@@ -848,6 +931,7 @@ function StatusScreen(props) {
848
931
  const leader = race.horses[0];
849
932
  const elapsedPct = elapsed(race);
850
933
  const timeLeft = formatDuration(race.time_left_seconds);
934
+ const lvl = levelInfo(own?.xp ?? 0);
851
935
  return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
852
936
  /* @__PURE__ */ jsxs4(Text5, { children: [
853
937
  "\u{1F3C7} TOKEN DERBY \u2500\u2500\u2500 ",
@@ -860,7 +944,13 @@ function StatusScreen(props) {
860
944
  /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", children: [
861
945
  /* @__PURE__ */ jsxs4(Text5, { children: [
862
946
  " ",
863
- ownHorseName
947
+ ownHorseName,
948
+ " ",
949
+ /* @__PURE__ */ jsxs4(Text5, { color: "cyan", children: [
950
+ "[Lvl. ",
951
+ lvl.level,
952
+ "]"
953
+ ] })
864
954
  ] }),
865
955
  /* @__PURE__ */ jsxs4(Text5, { children: [
866
956
  " ",
@@ -897,6 +987,10 @@ function StatusScreen(props) {
897
987
  "Time left: ",
898
988
  timeLeft
899
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
+ ] }),
900
994
  /* @__PURE__ */ jsxs4(Text5, { children: [
901
995
  "Last heartbeat: ",
902
996
  lastHeartbeatAgoSec === null ? "\u2014" : `${lastHeartbeatAgoSec}s ago`,
@@ -970,31 +1064,8 @@ function runHeartbeatLoop(opts) {
970
1064
  schedule(0);
971
1065
  }
972
1066
 
973
- // src/runtime/poll-loop.ts
974
- function runPollLoop(opts) {
975
- let timer = null;
976
- let stopped = false;
977
- const stop = () => {
978
- stopped = true;
979
- if (timer) clearTimeout(timer);
980
- timer = null;
981
- };
982
- opts.abortSignal.addEventListener("abort", stop, { once: true });
983
- const tick = async () => {
984
- if (stopped) return;
985
- try {
986
- const race = await opts.fetchRace();
987
- if (!stopped) opts.onSnapshot(race);
988
- } catch (err) {
989
- if (!stopped) opts.onError(err);
990
- }
991
- if (!stopped) timer = setTimeout(tick, opts.intervalMs);
992
- };
993
- timer = setTimeout(tick, 0);
994
- }
995
-
996
1067
  // src/tokens/transcripts.ts
997
- import * as fs4 from "fs/promises";
1068
+ import * as fs3 from "fs/promises";
998
1069
  import * as path4 from "path";
999
1070
  async function sumTokens() {
1000
1071
  const root = claudeProjectsDir();
@@ -1010,12 +1081,18 @@ async function sumTokens() {
1010
1081
  }
1011
1082
  async function sumOutputTokens() {
1012
1083
  const { input, output } = await sumTokens();
1013
- 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";
1014
1091
  }
1015
1092
  async function listJsonlFiles(root) {
1016
1093
  let projects;
1017
1094
  try {
1018
- projects = await fs4.readdir(root);
1095
+ projects = await fs3.readdir(root);
1019
1096
  } catch (e) {
1020
1097
  if (e?.code === "ENOENT") return [];
1021
1098
  throw e;
@@ -1025,12 +1102,17 @@ async function listJsonlFiles(root) {
1025
1102
  const projectDir = path4.join(root, project);
1026
1103
  let stat2;
1027
1104
  try {
1028
- stat2 = await fs4.stat(projectDir);
1105
+ stat2 = await fs3.stat(projectDir);
1029
1106
  } catch {
1030
1107
  continue;
1031
1108
  }
1032
1109
  if (!stat2.isDirectory()) continue;
1033
- const entries = await fs4.readdir(projectDir);
1110
+ let entries;
1111
+ try {
1112
+ entries = await fs3.readdir(projectDir);
1113
+ } catch {
1114
+ continue;
1115
+ }
1034
1116
  for (const entry of entries) {
1035
1117
  if (entry.endsWith(".jsonl")) out.push(path4.join(projectDir, entry));
1036
1118
  }
@@ -1043,7 +1125,7 @@ function addNum(value) {
1043
1125
  async function sumFile(file) {
1044
1126
  let raw;
1045
1127
  try {
1046
- raw = await fs4.readFile(file, "utf8");
1128
+ raw = await fs3.readFile(file, "utf8");
1047
1129
  } catch {
1048
1130
  return { input: 0, output: 0 };
1049
1131
  }
@@ -1096,14 +1178,6 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1096
1178
  }
1097
1179
  }, [race?.status]);
1098
1180
  useEffect(() => {
1099
- runPollLoop({
1100
- fetchRace: () => getRace(active.join_code),
1101
- intervalMs: POLL_INTERVAL_MS,
1102
- onSnapshot: (r) => setRace(r),
1103
- onError: () => {
1104
- },
1105
- abortSignal: ctrl.current.signal
1106
- });
1107
1181
  runHeartbeatLoop({
1108
1182
  sendHeartbeat: async (currentTokens) => {
1109
1183
  const resp = await heartbeat(
@@ -1129,6 +1203,7 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1129
1203
  onSuccess: (resp) => {
1130
1204
  setLastHbAt(/* @__PURE__ */ new Date());
1131
1205
  setLastHbOk(true);
1206
+ setRace(raceViewFrom(resp));
1132
1207
  if (resp.race_status === "finished") exit();
1133
1208
  },
1134
1209
  onError: (err) => {
@@ -1146,13 +1221,13 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1146
1221
  const sampler = setInterval(async () => {
1147
1222
  try {
1148
1223
  lastTokenSampleRef.current = await sumOutputTokens();
1149
- } catch {
1224
+ } catch (e) {
1225
+ console.error("[token-derby] token sampler failed:", e);
1150
1226
  }
1151
1227
  }, 5e3);
1152
1228
  sumOutputTokens().then((t) => {
1153
1229
  lastTokenSampleRef.current = t;
1154
- }).catch(() => {
1155
- });
1230
+ }).catch((e) => console.error("[token-derby] token sampler prime failed:", e));
1156
1231
  const controller = ctrl.current;
1157
1232
  return () => {
1158
1233
  clearInterval(sampler);
@@ -1179,6 +1254,15 @@ function RunRace({ active, startingBaseline, pendingMode, ownUserName }) {
1179
1254
  }
1180
1255
  );
1181
1256
  }
1257
+ function raceViewFrom(resp) {
1258
+ return {
1259
+ ...resp.race,
1260
+ status: resp.race_status,
1261
+ horses: resp.horses,
1262
+ server_time: resp.server_time,
1263
+ time_left_seconds: resp.time_left_seconds
1264
+ };
1265
+ }
1182
1266
  async function buildInitialState(args) {
1183
1267
  const runningTotal = await sumOutputTokens();
1184
1268
  if (args.rejoin) {
@@ -1231,12 +1315,21 @@ async function joinCommand(joinCode) {
1231
1315
  chosenColors = ownHorse.colors;
1232
1316
  isResume = true;
1233
1317
  } else {
1234
- const stable = await loadStable();
1235
- if (stable.horses.length === 0) {
1318
+ let horses;
1319
+ try {
1320
+ horses = (await listStable()).horses;
1321
+ } catch (e) {
1322
+ if (e instanceof ApiError) {
1323
+ console.error(`Error: ${e.code} ${e.message}`);
1324
+ return 1;
1325
+ }
1326
+ throw e;
1327
+ }
1328
+ if (horses.length === 0) {
1236
1329
  console.error("Your stable is empty. Run `token-derby stable create` first.");
1237
1330
  return 1;
1238
1331
  }
1239
- const picked = await pickHorse(stable.horses);
1332
+ const picked = await pickHorse(horses);
1240
1333
  if (!picked) {
1241
1334
  console.log("Cancelled.");
1242
1335
  return 1;
@@ -1248,9 +1341,7 @@ async function joinCommand(joinCode) {
1248
1341
  }
1249
1342
  let joinResp;
1250
1343
  try {
1251
- joinResp = await joinRace(code, {
1252
- horse: { stable_horse_id: chosenStableHorseId, name: chosenName, colors: chosenColors }
1253
- });
1344
+ joinResp = await joinRace(code, { stable_horse_id: chosenStableHorseId });
1254
1345
  } catch (e) {
1255
1346
  if (e instanceof ApiError) {
1256
1347
  if (e.code === "RACE_FULL") console.error("This race is full.");
@@ -1258,7 +1349,9 @@ async function joinCommand(joinCode) {
1258
1349
  else if (e.code === "RACE_NOT_FOUND") console.error(`No race with join code ${code}.`);
1259
1350
  else if (e.code === "VERSION_MISMATCH") console.error(e.message);
1260
1351
  else if (e.code === "DUPLICATE_HORSE") console.error(e.message);
1261
- else if (e.code === "IDENTITY_REQUIRED") console.error(`Error: ${e.message}`);
1352
+ else if (e.code === "STABLE_HORSE_NOT_FOUND") {
1353
+ console.error("That horse no longer exists in your stable. Try again.");
1354
+ } else if (e.code === "NOT_ORG_MEMBER") console.error(e.message);
1262
1355
  else console.error(`Error: ${e.code} ${e.message}`);
1263
1356
  return 1;
1264
1357
  }
@@ -1303,14 +1396,14 @@ async function pickHorse(horses) {
1303
1396
  }
1304
1397
 
1305
1398
  // src/commands/end.ts
1306
- import * as readline4 from "readline/promises";
1307
- import { stdin as stdin4, stdout as stdout4 } from "process";
1399
+ import * as readline3 from "readline/promises";
1400
+ import { stdin as stdin3, stdout as stdout3 } from "process";
1308
1401
  async function endCommand(adminCode) {
1309
1402
  if (!adminCode) {
1310
1403
  console.error("Usage: token-derby end <admin-code>");
1311
1404
  return 2;
1312
1405
  }
1313
- const rl = readline4.createInterface({ input: stdin4, output: stdout4 });
1406
+ const rl = readline3.createInterface({ input: stdin3, output: stdout3 });
1314
1407
  const answer = (await rl.question("End the race now and freeze final tokens? [y/N] ")).trim().toLowerCase();
1315
1408
  rl.close();
1316
1409
  if (answer !== "y" && answer !== "yes") {
@@ -1332,11 +1425,16 @@ async function endCommand(adminCode) {
1332
1425
  }
1333
1426
 
1334
1427
  // src/commands/init.ts
1335
- import * as readline5 from "readline/promises";
1336
- import { stdin as stdin5, stdout as stdout5 } from "process";
1337
- async function initCommand() {
1428
+ import * as readline4 from "readline/promises";
1429
+ import { stdin as stdin4, stdout as stdout4 } from "process";
1430
+ async function initCommand(reset = false) {
1431
+ if (reset) {
1432
+ await deleteIdentity();
1433
+ _resetIdentityCacheForTests();
1434
+ console.log("Removed local identity. Creating a new one\u2026");
1435
+ }
1338
1436
  const existing = await loadIdentity();
1339
- const rl = readline5.createInterface({ input: stdin5, output: stdout5 });
1437
+ const rl = readline4.createInterface({ input: stdin4, output: stdout4 });
1340
1438
  try {
1341
1439
  if (existing) {
1342
1440
  console.log(`Current jockey name: ${existing.display_name}`);
@@ -1350,10 +1448,25 @@ async function initCommand() {
1350
1448
  console.error(v2.error);
1351
1449
  return 1;
1352
1450
  }
1353
- const updated = { ...existing, display_name: v2.name };
1354
- await saveIdentity(updated);
1355
- console.log(`Updated jockey name to: ${updated.display_name}`);
1356
- return 0;
1451
+ try {
1452
+ const resp = await updateJockey({ display_name: v2.name });
1453
+ const updated = { ...existing, display_name: resp.display_name };
1454
+ await saveIdentity(updated);
1455
+ console.log(`Updated jockey name to: ${updated.display_name}`);
1456
+ return 0;
1457
+ } catch (e) {
1458
+ if (e instanceof ApiError) {
1459
+ if (e.code === "UNAUTHENTICATED") {
1460
+ console.error(
1461
+ "Server does not recognise this identity. Your account may have been wiped. Run `token-derby init --reset` to start fresh."
1462
+ );
1463
+ } else {
1464
+ console.error(`Error: ${e.code} ${e.message}`);
1465
+ }
1466
+ return 1;
1467
+ }
1468
+ throw e;
1469
+ }
1357
1470
  }
1358
1471
  const raw = (await rl.question("Jockey Name (use your real name please): ")).trim();
1359
1472
  const v = validateDisplayName(raw);
@@ -1361,26 +1474,152 @@ async function initCommand() {
1361
1474
  console.error(v.error);
1362
1475
  return 1;
1363
1476
  }
1364
- const identity = {
1365
- user_id: generateUserId(),
1366
- display_name: v.name,
1367
- created_at: (/* @__PURE__ */ new Date()).toISOString()
1368
- };
1369
- await saveIdentity(identity);
1477
+ try {
1478
+ const resp = await initJockey({ display_name: v.name });
1479
+ const identity = {
1480
+ user_id: resp.user_id,
1481
+ display_name: resp.display_name,
1482
+ secret_token: resp.secret_token,
1483
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
1484
+ };
1485
+ await saveIdentity(identity);
1486
+ _resetIdentityCacheForTests();
1487
+ console.log("");
1488
+ console.log(`Welcome, ${identity.display_name}!`);
1489
+ console.log("Your identity has been created on the server.");
1490
+ console.log("You can now create a stable and join races.");
1491
+ console.log("");
1492
+ console.log(" \u26A0 Your secret token is stored locally in identity.json.");
1493
+ console.log(" If you lose it, you cannot recover this account \u2014 you would");
1494
+ console.log(" need to run `token-derby init --reset` and rebuild your stable.");
1495
+ return 0;
1496
+ } catch (e) {
1497
+ if (e instanceof ApiError) {
1498
+ console.error(`Error: ${e.code} ${e.message}`);
1499
+ return 1;
1500
+ }
1501
+ throw e;
1502
+ }
1503
+ } finally {
1504
+ rl.close();
1505
+ }
1506
+ }
1507
+
1508
+ // src/commands/org-create.ts
1509
+ import * as readline5 from "readline/promises";
1510
+ import { stdin as stdin5, stdout as stdout5 } from "process";
1511
+ async function orgCreateCommand() {
1512
+ const rl = readline5.createInterface({ input: stdin5, output: stdout5 });
1513
+ try {
1514
+ const name = (await rl.question(`Organisation name (1\u2013${ORG_NAME_MAX_LENGTH} alphanumeric chars): `)).trim();
1515
+ if (!ORG_NAME_PATTERN.test(name)) {
1516
+ console.error(`Name must be 1\u2013${ORG_NAME_MAX_LENGTH} alphanumeric characters (no spaces or symbols).`);
1517
+ return 1;
1518
+ }
1519
+ const resp = await createOrganisation({ name });
1370
1520
  console.log("");
1371
- console.log(`Welcome, ${identity.display_name}!`);
1372
- console.log(`Your identity is saved. You can now create a stable and join races.`);
1521
+ console.log(` Organisation created: ${resp.org_name}`);
1522
+ 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");
1523
+ console.log(` \u2551 JOIN TOKEN: ${resp.org_join_token.padEnd(43)}\u2551`);
1524
+ 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");
1525
+ console.log(" \u26A0 Share this token to invite members. Treat it as a secret.");
1526
+ console.log("");
1527
+ console.log(` Members join with: token-derby organisation join ${resp.org_join_token}`);
1528
+ console.log(` Create org races: token-derby create --organisation ${resp.org_name}`);
1373
1529
  return 0;
1530
+ } catch (e) {
1531
+ if (e instanceof ApiError) {
1532
+ console.error(`Error: ${e.code} ${e.message}`);
1533
+ return 1;
1534
+ }
1535
+ throw e;
1374
1536
  } finally {
1375
1537
  rl.close();
1376
1538
  }
1377
1539
  }
1378
1540
 
1541
+ // src/commands/org-join.ts
1542
+ async function orgJoinCommand(token) {
1543
+ if (!token) {
1544
+ console.error("Usage: token-derby organisation join <join-token>");
1545
+ return 2;
1546
+ }
1547
+ try {
1548
+ const resp = await joinOrganisation({ join_token: token });
1549
+ console.log(`Joined organisation: ${resp.org_name}`);
1550
+ return 0;
1551
+ } catch (e) {
1552
+ if (e instanceof ApiError) {
1553
+ console.error(`Error: ${e.code} ${e.message}`);
1554
+ return 1;
1555
+ }
1556
+ throw e;
1557
+ }
1558
+ }
1559
+
1560
+ // src/commands/org-list.ts
1561
+ async function orgListCommand() {
1562
+ try {
1563
+ const resp = await listOrganisations();
1564
+ if (resp.organisations.length === 0) {
1565
+ console.log("You are not in any organisations.");
1566
+ console.log("Create one with: token-derby organisation create");
1567
+ console.log("Or join one with: token-derby organisation join <token>");
1568
+ return 0;
1569
+ }
1570
+ console.log(`Your organisations (${resp.organisations.length}):`);
1571
+ for (const o of resp.organisations) {
1572
+ console.log(` \u2022 ${o.org_name}`);
1573
+ }
1574
+ return 0;
1575
+ } catch (e) {
1576
+ if (e instanceof ApiError) {
1577
+ console.error(`Error: ${e.code} ${e.message}`);
1578
+ return 1;
1579
+ }
1580
+ throw e;
1581
+ }
1582
+ }
1583
+
1584
+ // src/commands/org-info.ts
1585
+ async function orgInfoCommand(name) {
1586
+ if (!name) {
1587
+ console.error("Usage: token-derby organisation info <name>");
1588
+ return 2;
1589
+ }
1590
+ if (!ORG_NAME_PATTERN.test(name)) {
1591
+ console.error("Organisation name must be 1\u201312 alphanumeric characters.");
1592
+ return 2;
1593
+ }
1594
+ try {
1595
+ const resp = await getOrganisation(name);
1596
+ console.log(`Organisation: ${resp.org_name}`);
1597
+ console.log(`Created: ${resp.created_at} by ${resp.creator_user_name}`);
1598
+ console.log("");
1599
+ 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");
1600
+ console.log(` \u2551 JOIN TOKEN: ${resp.org_join_token.padEnd(43)}\u2551`);
1601
+ 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");
1602
+ console.log(" \u26A0 Treat the token as a secret \u2014 anyone with it can join.");
1603
+ console.log("");
1604
+ console.log(` Members join with: token-derby organisation join ${resp.org_join_token}`);
1605
+ return 0;
1606
+ } catch (e) {
1607
+ if (e instanceof ApiError) {
1608
+ console.error(`Error: ${e.code} ${e.message}`);
1609
+ return 1;
1610
+ }
1611
+ throw e;
1612
+ }
1613
+ }
1614
+
1379
1615
  // src/bin.ts
1380
1616
  var HELP = `token-derby v${CLI_VERSION}
1381
1617
 
1382
1618
  Identity:
1383
1619
  token-derby init Set up your jockey identity (run this first)
1620
+ Re-running renames you on the server.
1621
+ token-derby init --reset Wipe local identity and create a fresh account.
1622
+ Your previous stable is abandoned on the server.
1384
1623
 
1385
1624
  Stable management:
1386
1625
  token-derby stable create Make a new horse (interactive)
@@ -1388,8 +1627,17 @@ Stable management:
1388
1627
  token-derby stable edit <name> Edit an existing horse's colors
1389
1628
  token-derby stable delete <name> Remove a horse from your stable
1390
1629
 
1630
+ Organisations:
1631
+ token-derby organisation create Create a new organisation (interactive)
1632
+ token-derby organisation join <token> Join an organisation with a join token
1633
+ token-derby organisation info <name> Show an org's join token (members only)
1634
+ token-derby organisation list Show organisations you're a member of
1635
+
1391
1636
  Races:
1392
- token-derby create Create a new race (interactive)
1637
+ token-derby create [--organisation <name>]
1638
+ Create a new race (interactive). When
1639
+ --organisation is set, only members of
1640
+ that org can join.
1393
1641
  token-derby join <join-code> Join (or resume) a race
1394
1642
  token-derby end <admin-code> End a race early
1395
1643
 
@@ -1408,7 +1656,10 @@ async function main() {
1408
1656
  console.log(CLI_VERSION);
1409
1657
  return 0;
1410
1658
  }
1411
- if (cmd === "init") return initCommand();
1659
+ if (cmd === "init") {
1660
+ const reset = argv.slice(1).includes("--reset");
1661
+ return initCommand(reset);
1662
+ }
1412
1663
  const identity = await loadIdentity();
1413
1664
  if (!identity) {
1414
1665
  console.error("Run `token-derby init` to set up your identity before using any other command.");
@@ -1424,13 +1675,34 @@ async function main() {
1424
1675
  console.error("Try: stable create | stable list | stable edit <name> | stable delete <name>");
1425
1676
  return 2;
1426
1677
  }
1427
- if (cmd === "create") return createRaceCommand();
1678
+ if (cmd === "organisation" || cmd === "org") {
1679
+ const sub = argv[1];
1680
+ if (sub === "create") return orgCreateCommand();
1681
+ if (sub === "join") return orgJoinCommand(argv[2]);
1682
+ if (sub === "info") return orgInfoCommand(argv[2]);
1683
+ if (sub === "list") return orgListCommand();
1684
+ console.error(`Unknown organisation subcommand: ${sub ?? "(none)"}`);
1685
+ console.error("Try: organisation create | organisation join <token> | organisation info <name> | organisation list");
1686
+ return 2;
1687
+ }
1688
+ if (cmd === "create") {
1689
+ const orgName = parseFlag(argv.slice(1), "--organisation");
1690
+ return createRaceCommand(orgName);
1691
+ }
1428
1692
  if (cmd === "join") return joinCommand(argv[1]);
1429
1693
  if (cmd === "end") return endCommand(argv[1]);
1430
1694
  console.error(`Unknown command: ${cmd}`);
1431
1695
  console.error(HELP);
1432
1696
  return 2;
1433
1697
  }
1698
+ function parseFlag(args, flag) {
1699
+ for (let i = 0; i < args.length; i++) {
1700
+ if (args[i] === flag) return args[i + 1];
1701
+ const eq = `${flag}=`;
1702
+ if (args[i]?.startsWith(eq)) return args[i].slice(eq.length);
1703
+ }
1704
+ return void 0;
1705
+ }
1434
1706
  main().then(
1435
1707
  (code) => process.exit(code),
1436
1708
  (err) => {