@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/README.md +1 -0
- package/dist/bin.js +603 -310
- package/dist/bin.js.map +1 -1
- package/package.json +1 -1
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/
|
|
322
|
-
|
|
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/
|
|
347
|
-
async function
|
|
415
|
+
// src/identity/identity.ts
|
|
416
|
+
async function loadIdentity() {
|
|
348
417
|
try {
|
|
349
|
-
const raw = await fs.readFile(
|
|
418
|
+
const raw = await fs.readFile(identityFile(), "utf8");
|
|
350
419
|
const parsed = JSON.parse(raw);
|
|
351
|
-
if (
|
|
352
|
-
|
|
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
|
|
355
|
-
|
|
356
|
-
throw e;
|
|
425
|
+
if (e?.code === "ENOENT") return null;
|
|
426
|
+
return null;
|
|
357
427
|
}
|
|
358
428
|
}
|
|
359
|
-
async function
|
|
429
|
+
async function saveIdentity(identity) {
|
|
360
430
|
await fs.mkdir(homeDir(), { recursive: true });
|
|
361
|
-
await fs.writeFile(
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
stable.horses = stable.horses.filter((h) => h.name !== name);
|
|
373
|
-
await saveStable(stable);
|
|
465
|
+
function _resetIdentityCacheForTests() {
|
|
466
|
+
identityCache = null;
|
|
374
467
|
}
|
|
375
|
-
function
|
|
376
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
if (existing) {
|
|
577
|
+
try {
|
|
578
|
+
await createStableHorse({ name, colors });
|
|
390
579
|
app.unmount();
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (
|
|
395
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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
|
|
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.
|
|
654
|
+
] }, h.stable_horse_id))
|
|
448
655
|
] });
|
|
449
656
|
}
|
|
450
657
|
|
|
451
658
|
// src/commands/stable-delete.ts
|
|
452
|
-
import * as
|
|
453
|
-
import { stdin
|
|
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
|
-
|
|
492
|
-
|
|
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
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
|
526
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
651
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
1105
|
+
stat2 = await fs3.stat(projectDir);
|
|
1008
1106
|
} catch {
|
|
1009
1107
|
continue;
|
|
1010
1108
|
}
|
|
1011
1109
|
if (!stat2.isDirectory()) continue;
|
|
1012
|
-
const entries = await
|
|
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
|
|
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
|
-
|
|
1212
|
-
|
|
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(
|
|
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, {
|
|
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 === "
|
|
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
|
|
1281
|
-
import { stdin as
|
|
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 =
|
|
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
|
|
1310
|
-
import { stdin as
|
|
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 =
|
|
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
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
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
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
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(`
|
|
1346
|
-
console.log(`
|
|
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
|
|
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")
|
|
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 === "
|
|
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) => {
|