@emesoft/tokenhub 0.8.2 → 0.8.4

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/bin/tokenhub.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
3
  import { adminConfigPath, removeAdminConfig } from "../lib/admin.js";
4
- import { showBanner } from "../lib/banner.js";
4
+ import { isInteractive, showBanner } from "../lib/banner.js";
5
5
  import { parseDashboardArgs, runDashboard } from "../lib/dashboard.js";
6
6
  import { runOverlayInstall } from "../lib/overlay.js";
7
7
  import { promptTenant, runConnect, TENANT_DOMAIN } from "../lib/pair.js";
@@ -53,6 +53,9 @@ async function main(argv) {
53
53
  }
54
54
  const file = settingsPath();
55
55
  if (command === "connect") {
56
+ if (isInteractive()) {
57
+ await showBanner({ showActions: false });
58
+ }
56
59
  const controller = new AbortController();
57
60
  process.once("SIGINT", () => controller.abort());
58
61
  let poolUrl = arg;
package/lib/banner.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export interface BannerOptions {
2
2
  stream?: NodeJS.WriteStream;
3
3
  animate?: boolean;
4
+ showActions?: boolean;
4
5
  }
5
6
 
6
7
  export function isInteractive(stream?: NodeJS.WriteStream): boolean;
package/lib/banner.js CHANGED
@@ -6,7 +6,6 @@ const TOKEN_COLOR = "\x1b[38;5;39;1m"; // Electric cyan bold
6
6
  const HUB_COLOR = "\x1b[38;5;208;1m"; // TokenHub warm amber/clay bold
7
7
  const CYAN = "\x1b[38;5;39m";
8
8
  const MUTED = "\x1b[38;5;244m";
9
- const GREEN = "\x1b[38;5;114m";
10
9
  const RESET = "\x1b[0m";
11
10
  const BOLD = "\x1b[1m";
12
11
 
@@ -25,15 +24,15 @@ export const LOGO_PARTS = [
25
24
  },
26
25
  {
27
26
  token: " ██║ ██║ ██║██╔═██╗ ██╔══╝ ██║╚██╗██║",
28
- hub: "██╔══██║██║ ██║██╔═══╝ ",
27
+ hub: "██╔══██║██║ ██║██╔══██╗",
29
28
  },
30
29
  {
31
30
  token: " ██║ ╚██████╔╝██║ ██╗███████╗██║ ╚████║",
32
- hub: "██║ ██║╚██████╔╝██║ ",
31
+ hub: "██║ ██║╚██████╔╝██████╔╝",
33
32
  },
34
33
  {
35
34
  token: " ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝",
36
- hub: "╚═╝ ╚═╝ ╚═════╝ ╚═╝ ",
35
+ hub: "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ",
37
36
  },
38
37
  ];
39
38
 
@@ -72,9 +71,10 @@ export function isInteractive(stream = process.stdout) {
72
71
  * @param {object} [options]
73
72
  * @param {NodeJS.WriteStream} [options.stream]
74
73
  * @param {boolean} [options.animate]
74
+ * @param {boolean} [options.showActions]
75
75
  * @returns {Promise<void>}
76
76
  */
77
- export async function showBanner({ stream = process.stdout, animate } = {}) {
77
+ export async function showBanner({ stream = process.stdout, animate, showActions = true } = {}) {
78
78
  const interactive = animate ?? isInteractive(stream);
79
79
  const color = !process.env.NO_COLOR && (interactive || Boolean(stream?.isTTY));
80
80
 
@@ -91,8 +91,10 @@ export async function showBanner({ stream = process.stdout, animate } = {}) {
91
91
  stream.write("\n");
92
92
  stream.write(color ? `${CYAN}${SUBTITLE}${RESET}\n` : `${SUBTITLE}\n`);
93
93
  stream.write(color ? `${MUTED}${DIVIDER}${RESET}\n` : `${DIVIDER}\n`);
94
- for (const action of ACTIONS) {
95
- stream.write(color ? `${action}\n` : `${action.replace(/\x1b\[[0-9;]*m/g, "")}\n`);
94
+ if (showActions) {
95
+ for (const action of ACTIONS) {
96
+ stream.write(color ? `${action}\n` : `${action.replace(/\x1b\[[0-9;]*m/g, "")}\n`);
97
+ }
96
98
  }
97
99
  stream.write("\n");
98
100
  return;
@@ -103,12 +105,6 @@ export async function showBanner({ stream = process.stdout, animate } = {}) {
103
105
 
104
106
  // Draw the logo lines progressively with a smooth typewriter scan
105
107
  for (const row of LOGO_PARTS) {
106
- const tokenPart = `${TOKEN_COLOR}${row.token}`;
107
- const hubPart = `${HUB_COLOR}${row.hub}${RESET}\n`;
108
-
109
- // Progressive character scan per line
110
- const fullLine = `${tokenPart}${hubPart}`;
111
- // Split into 4 chunks per line for high-tech sweep effect
112
108
  const chunkSize = 16;
113
109
  let idx = 0;
114
110
  while (idx < row.token.length) {
@@ -146,12 +142,13 @@ export async function showBanner({ stream = process.stdout, animate } = {}) {
146
142
  await sleep(8);
147
143
  }
148
144
  stream.write(`${RESET}\n`);
149
- await sleep(40);
150
145
 
151
- // Quick actions lines
152
- for (const action of ACTIONS) {
153
- stream.write(`${action}\n`);
146
+ if (showActions) {
154
147
  await sleep(40);
148
+ for (const action of ACTIONS) {
149
+ stream.write(`${action}\n`);
150
+ await sleep(40);
151
+ }
155
152
  }
156
153
 
157
154
  stream.write("\n");
package/lib/pair.d.ts CHANGED
@@ -1,6 +1,18 @@
1
1
  export const TENANT_DOMAIN: string;
2
2
  export function resolvePoolUrl(input: string | undefined): string;
3
- export function promptTenant(streams?: { input?: NodeJS.ReadableStream & { isTTY?: boolean }; output?: NodeJS.WritableStream }): Promise<string>;
3
+ export function fetchTenants(platformUrl?: string): Promise<Array<{ slug: string; name: string }>>;
4
+ export function selectChoice(options: {
5
+ message: string;
6
+ choices: Array<{ label: string; value: string }>;
7
+ input?: NodeJS.ReadableStream & { isTTY?: boolean; setRawMode?: (mode: boolean) => void };
8
+ output?: NodeJS.WritableStream;
9
+ }): Promise<string>;
10
+ export function promptTenant(options?: {
11
+ input?: NodeJS.ReadableStream & { isTTY?: boolean; setRawMode?: (mode: boolean) => void };
12
+ output?: NodeJS.WritableStream;
13
+ fetchFn?: (platformUrl?: string) => Promise<Array<{ slug: string; name: string }>>;
14
+ tenantList?: Array<{ slug: string; name: string }>;
15
+ }): Promise<string>;
4
16
  export function openBrowser(url: string, platform?: string): void;
5
17
  export function runConnect(options: {
6
18
  /** Tenant slug (`acme`) or pool URL; required. */
package/lib/pair.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { hostname } from "node:os";
3
+ import { emitKeypressEvents } from "node:readline";
3
4
  import { createInterface } from "node:readline/promises";
4
5
  import { writeAdminConfig } from "./admin.js";
5
6
  import { connectSettings, normalizeUrl, overridingEnvVars, readSettings } from "./settings.js";
@@ -27,13 +28,129 @@ export function resolvePoolUrl(input) {
27
28
  }
28
29
 
29
30
  /**
30
- * Ask for the tenant slug on the terminal.
31
- * @param {{ input?: NodeJS.ReadableStream & { isTTY?: boolean }, output?: NodeJS.WritableStream }} [streams] Streams to prompt on (tests).
32
- * @returns {Promise<string>} What was typed.
31
+ * Fetch available active tenants from the platform API.
32
+ * @param {string} [platformUrl] Address of the platform or tenant host.
33
+ * @returns {Promise<Array<{ slug: string, name: string }>>}
34
+ */
35
+ export async function fetchTenants(platformUrl = `https://${TENANT_DOMAIN}`) {
36
+ try {
37
+ const res = await fetch(`${platformUrl}/api/tenants`, { signal: AbortSignal.timeout(3000) });
38
+ if (!res.ok) return [{ slug: "emesoft", name: "Emesoft" }];
39
+ const data = await res.json().catch(() => null);
40
+ if (data?.ok && Array.isArray(data.tenants) && data.tenants.length > 0) {
41
+ return data.tenants;
42
+ }
43
+ } catch {
44
+ // offline or unreachable
45
+ }
46
+ return [{ slug: "emesoft", name: "Emesoft" }];
47
+ }
48
+
49
+ /**
50
+ * Interactive arrow-key select prompt in terminal.
51
+ * @param {object} options
52
+ * @param {string} options.message Question/prompt to display.
53
+ * @param {Array<{ label: string, value: string }>} options.choices List of choices.
54
+ * @param {NodeJS.ReadableStream & { isTTY?: boolean, setRawMode?: (mode: boolean) => void }} [options.input]
55
+ * @param {NodeJS.WritableStream} [options.output]
56
+ * @returns {Promise<string>} Selected value.
57
+ */
58
+ export function selectChoice({ message, choices, input = process.stdin, output = process.stdout }) {
59
+ return new Promise((resolve) => {
60
+ let selected = 0;
61
+ const isRaw = input.isRaw;
62
+ emitKeypressEvents(input);
63
+ if (typeof input.setRawMode === "function") input.setRawMode(true);
64
+
65
+ const CYAN = "\x1b[36;1m";
66
+ const RESET = "\x1b[0m";
67
+ const DIM = "\x1b[2m";
68
+ const HIDE_CURSOR = "\x1b[?25l";
69
+ const SHOW_CURSOR = "\x1b[?25h";
70
+
71
+ function render(first = false) {
72
+ if (!first) {
73
+ output.write(`\x1b[${choices.length}A`);
74
+ }
75
+ for (let i = 0; i < choices.length; i++) {
76
+ const isCurrent = i === selected;
77
+ const prefix = isCurrent ? `${CYAN}❯ ` : " ";
78
+ const label = isCurrent ? `${CYAN}${choices[i].label}${RESET}` : choices[i].label;
79
+ output.write(`\x1b[2K\r${prefix}${label}\n`);
80
+ }
81
+ }
82
+
83
+ output.write(`${HIDE_CURSOR}${message} ${DIM}(Use ↑/↓ arrows, Enter to select)${RESET}\n`);
84
+ render(true);
85
+
86
+ function cleanup() {
87
+ output.write(SHOW_CURSOR);
88
+ input.removeListener("keypress", onKeypress);
89
+ if (typeof input.setRawMode === "function") input.setRawMode(isRaw ?? false);
90
+ }
91
+
92
+ function onKeypress(str, key) {
93
+ if (key && key.name === "c" && key.ctrl) {
94
+ cleanup();
95
+ process.exit(130);
96
+ } else if (key && (key.name === "up" || key.name === "k")) {
97
+ selected = (selected - 1 + choices.length) % choices.length;
98
+ render();
99
+ } else if (key && (key.name === "down" || key.name === "j")) {
100
+ selected = (selected + 1) % choices.length;
101
+ render();
102
+ } else if (key && (key.name === "return" || key.name === "enter")) {
103
+ cleanup();
104
+ resolve(choices[selected].value);
105
+ }
106
+ }
107
+
108
+ input.on("keypress", onKeypress);
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Ask for the tenant slug on the terminal (interactive select or text input).
114
+ * @param {object} [options] Streams and options for prompting (tests).
115
+ * @param {NodeJS.ReadableStream & { isTTY?: boolean, setRawMode?: (mode: boolean) => void }} [options.input]
116
+ * @param {NodeJS.WritableStream} [options.output]
117
+ * @param {(platformUrl?: string) => Promise<Array<{ slug: string, name: string }>>} [options.fetchFn]
118
+ * @param {Array<{ slug: string, name: string }>} [options.tenantList]
119
+ * @returns {Promise<string>} What was selected or typed.
33
120
  * @throws {Error} When stdin is not interactive.
34
121
  */
35
- export async function promptTenant({ input = process.stdin, output = process.stdout } = {}) {
122
+ export async function promptTenant({
123
+ input = process.stdin,
124
+ output = process.stdout,
125
+ fetchFn = fetchTenants,
126
+ tenantList,
127
+ } = {}) {
36
128
  if (!input.isTTY) throw new Error(`Name your tenant, e.g. \`tokenhub connect acme\` for https://acme.${TENANT_DOMAIN} (no terminal to ask on).`);
129
+
130
+ const canRaw = typeof input.setRawMode === "function";
131
+ if (canRaw) {
132
+ const list = tenantList ?? (await fetchFn());
133
+ const choices = list.map((t) => ({
134
+ label: `${t.slug} (${t.name})`,
135
+ value: t.slug,
136
+ }));
137
+ choices.push({
138
+ label: "Other... (enter custom tenant)",
139
+ value: "__custom__",
140
+ });
141
+
142
+ const chosen = await selectChoice({
143
+ message: `? Select your TokenHub tenant:`,
144
+ choices,
145
+ input,
146
+ output,
147
+ });
148
+
149
+ if (chosen !== "__custom__") {
150
+ return chosen;
151
+ }
152
+ }
153
+
37
154
  const rl = createInterface({ input, output });
38
155
  try {
39
156
  return await rl.question(`Tenant name (the part before .${TENANT_DOMAIN}): `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emesoft/tokenhub",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "Connect Claude Code to a TokenHub pool in one command, and track your spend from the terminal",
5
5
  "keywords": [
6
6
  "claude",