@tokenbuddy/tb-admin 1.0.14 → 1.0.27

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.
Files changed (55) hide show
  1. package/dist/src/bootstrap-registry.d.ts +1 -0
  2. package/dist/src/bootstrap-registry.d.ts.map +1 -1
  3. package/dist/src/bootstrap-registry.js.map +1 -1
  4. package/dist/src/cli.d.ts.map +1 -1
  5. package/dist/src/cli.js +294 -13
  6. package/dist/src/cli.js.map +1 -1
  7. package/dist/src/client.d.ts +12 -3
  8. package/dist/src/client.d.ts.map +1 -1
  9. package/dist/src/client.js +12 -8
  10. package/dist/src/client.js.map +1 -1
  11. package/dist/src/display-format.d.ts +39 -0
  12. package/dist/src/display-format.d.ts.map +1 -0
  13. package/dist/src/display-format.js +354 -0
  14. package/dist/src/display-format.js.map +1 -0
  15. package/dist/src/server-cmd.d.ts +25 -1
  16. package/dist/src/server-cmd.d.ts.map +1 -1
  17. package/dist/src/server-cmd.js +116 -16
  18. package/dist/src/server-cmd.js.map +1 -1
  19. package/dist/src/ui-actions.d.ts +90 -0
  20. package/dist/src/ui-actions.d.ts.map +1 -0
  21. package/dist/src/ui-actions.js +823 -0
  22. package/dist/src/ui-actions.js.map +1 -0
  23. package/dist/src/ui-command.d.ts +4 -0
  24. package/dist/src/ui-command.d.ts.map +1 -0
  25. package/dist/src/ui-command.js +37 -0
  26. package/dist/src/ui-command.js.map +1 -0
  27. package/dist/src/ui-server.d.ts +22 -0
  28. package/dist/src/ui-server.d.ts.map +1 -0
  29. package/dist/src/ui-server.js +261 -0
  30. package/dist/src/ui-server.js.map +1 -0
  31. package/dist/src/ui-state.d.ts +140 -0
  32. package/dist/src/ui-state.d.ts.map +1 -0
  33. package/dist/src/ui-state.js +438 -0
  34. package/dist/src/ui-state.js.map +1 -0
  35. package/dist/src/ui-static.d.ts +2 -0
  36. package/dist/src/ui-static.d.ts.map +1 -0
  37. package/dist/src/ui-static.js +469 -0
  38. package/dist/src/ui-static.js.map +1 -0
  39. package/dist/src/upstream-balance-probe.d.ts +41 -0
  40. package/dist/src/upstream-balance-probe.d.ts.map +1 -0
  41. package/dist/src/upstream-balance-probe.js +379 -0
  42. package/dist/src/upstream-balance-probe.js.map +1 -0
  43. package/package.json +1 -1
  44. package/src/bootstrap-registry.ts +1 -0
  45. package/src/cli.ts +335 -13
  46. package/src/client.ts +13 -8
  47. package/src/display-format.ts +398 -0
  48. package/src/server-cmd.ts +145 -20
  49. package/src/ui-actions.ts +958 -0
  50. package/src/ui-command.ts +39 -0
  51. package/src/ui-server.ts +322 -0
  52. package/src/ui-state.ts +614 -0
  53. package/src/ui-static.ts +472 -0
  54. package/src/upstream-balance-probe.ts +505 -0
  55. package/tests/admin.test.ts +1404 -2
@@ -0,0 +1,398 @@
1
+ // Shared display formatters for TokenBuddy UI surfaces.
2
+ //
3
+ // This module is a portable copy of `packages/tb-ui/src/lib/display-format.ts`
4
+ // and the canonical formatter functions declared in `DESIGN.md`
5
+ // ("Formatter Implementation" section). Keep both files in sync — the
6
+ // rule from DESIGN.md is:
7
+ // "Do not add new per-page formatting helpers for these domains.
8
+ // If a page needs a different display, add an explicit option
9
+ // to the shared formatter."
10
+ //
11
+ // Differences vs. the React copy:
12
+ // 1. No React-only imports.
13
+ // 2. Adds `formatBalanceAmount` and `formatSellerStatus` for the
14
+ // admin surface, because the admin UI surfaces live balance
15
+ // numbers and node-status dots that the buyer UI never shows.
16
+
17
+ export const UNKNOWN_VALUE = "—";
18
+
19
+ const PAD2 = (n: number) => (n < 10 ? `0${n}` : `${n}`);
20
+
21
+ export function formatTokenCount(
22
+ value: number | undefined | null,
23
+ options: { compact?: boolean } = {}
24
+ ): string {
25
+ if (!Number.isFinite(value)) return UNKNOWN_VALUE;
26
+ const numeric = value as number;
27
+ if (options.compact === false) return Math.round(numeric).toLocaleString("en-US");
28
+ if (numeric < 10_000) return Math.round(numeric).toLocaleString("en-US");
29
+ if (numeric < 1_000_000) return `${(numeric / 1_000).toFixed(1)}K`;
30
+ if (numeric < 1_000_000_000) return `${(numeric / 1_000_000).toFixed(1)}M`;
31
+ return `${(numeric / 1_000_000_000).toFixed(2)}B`;
32
+ }
33
+
34
+ export function formatTokenPair(
35
+ input: number | undefined | null,
36
+ output: number | undefined | null,
37
+ options: { compact?: boolean; separator?: string } = {}
38
+ ): string {
39
+ const separator = options.separator ?? " / ";
40
+ return `In ${formatTokenCount(input, options)}${separator}Out ${formatTokenCount(output, options)}`;
41
+ }
42
+
43
+ export function formatCount(value: number | undefined | null): string {
44
+ if (!Number.isFinite(value)) return UNKNOWN_VALUE;
45
+ return Math.round(value as number).toLocaleString("en-US");
46
+ }
47
+
48
+ export function formatMoney(
49
+ micros: number | undefined | null,
50
+ options: { digits?: number; ledger?: boolean; signed?: boolean } = {}
51
+ ): string {
52
+ if (!Number.isFinite(micros)) return UNKNOWN_VALUE;
53
+ const amount = Math.abs((micros as number) / 1_000_000);
54
+ const digits = options.digits ?? (options.ledger && amount < 0.01 ? 6 : 4);
55
+ const formatted = `$${((micros as number) / 1_000_000).toFixed(digits)}`;
56
+ return options.signed && (micros as number) >= 0 ? `+${formatted}` : formatted;
57
+ }
58
+
59
+ export function formatMoneyPair(
60
+ actualMicros: number | undefined | null,
61
+ referenceMicros: number | undefined | null,
62
+ options: { digits?: number } = {}
63
+ ): string {
64
+ if (!Number.isFinite(actualMicros) && !Number.isFinite(referenceMicros)) return UNKNOWN_VALUE;
65
+ return `${formatMoney(actualMicros, options)} / ${formatMoney(referenceMicros, options)}`;
66
+ }
67
+
68
+ export function formatDuration(valueMs: number | undefined | null): string {
69
+ if (!Number.isFinite(valueMs)) return UNKNOWN_VALUE;
70
+ const ms = Math.max(0, Math.round(valueMs as number));
71
+ if (ms < 1000) return `${ms}ms`;
72
+ return `${(ms / 1000).toFixed(2)}s`;
73
+ }
74
+
75
+ export function formatPercent(value: number | undefined | null): string {
76
+ if (!Number.isFinite(value)) return UNKNOWN_VALUE;
77
+ return `${Math.round((value as number) * 100)}%`;
78
+ }
79
+
80
+ export function formatDiscountRatio(discountRatio: number | undefined | null): string {
81
+ if (!Number.isFinite(discountRatio)) return UNKNOWN_VALUE;
82
+ const discount = Math.max(0, 1 - (discountRatio as number));
83
+ return formatPercent(discount);
84
+ }
85
+
86
+ export function formatPriceMicrosPerMillion(value: number | undefined | null): string {
87
+ return formatMoney(value, { digits: 4 });
88
+ }
89
+
90
+ export function formatPricePair(
91
+ inputMicros: number | undefined | null,
92
+ outputMicros: number | undefined | null
93
+ ): string {
94
+ return formatMoneyPair(inputMicros, outputMicros, { digits: 4 });
95
+ }
96
+
97
+ export function normalizeStatusLabel(status: string | undefined | null): string {
98
+ if (!status) return UNKNOWN_VALUE;
99
+ return status.trim().toLowerCase().replaceAll("_", " ");
100
+ }
101
+
102
+ export type StatusTone = "green" | "amber" | "red" | "blue" | "gray";
103
+
104
+ export function statusTone(status: string | undefined | null): StatusTone {
105
+ const normalized = normalizeStatusLabel(status);
106
+ if (
107
+ normalized === "ok" ||
108
+ normalized === "online" ||
109
+ normalized === "configured" ||
110
+ normalized === "settled" ||
111
+ normalized === "completed" ||
112
+ normalized === "success" ||
113
+ normalized === "active" ||
114
+ normalized === "healthy"
115
+ ) {
116
+ return "green";
117
+ }
118
+ if (
119
+ normalized === "fallback" ||
120
+ normalized === "pending" ||
121
+ normalized === "degraded" ||
122
+ normalized === "preview" ||
123
+ normalized === "draining" ||
124
+ normalized === "busy capacity" ||
125
+ normalized === "auth unknown"
126
+ ) {
127
+ return "amber";
128
+ }
129
+ if (
130
+ normalized === "failed" ||
131
+ normalized === "error" ||
132
+ normalized === "canceled" ||
133
+ normalized === "unhealthy" ||
134
+ normalized === "offline"
135
+ ) {
136
+ return "red";
137
+ }
138
+ if (normalized === "running") return "blue";
139
+ return "gray";
140
+ }
141
+
142
+ export function formatHash(value: string | undefined | null, length = 32): string {
143
+ if (!value) return UNKNOWN_VALUE;
144
+ return value.length > length ? `${value.slice(0, length)}...` : value;
145
+ }
146
+
147
+ export function formatSellerId(value: string | undefined | null): string {
148
+ if (!value) return UNKNOWN_VALUE;
149
+ if (value.startsWith("tbs-") && value.length > 10) return value.slice(0, 10);
150
+ if (value.length <= 12) return value;
151
+ return value.slice(0, 12);
152
+ }
153
+
154
+ export function formatTimeCompact(iso: string | undefined | null): string {
155
+ if (!iso) return UNKNOWN_VALUE;
156
+ const d = new Date(iso);
157
+ if (Number.isNaN(d.getTime())) return UNKNOWN_VALUE;
158
+ const now = new Date();
159
+ const sameDay =
160
+ d.getFullYear() === now.getFullYear() &&
161
+ d.getMonth() === now.getMonth() &&
162
+ d.getDate() === now.getDate();
163
+ const time = `${PAD2(d.getHours())}:${PAD2(d.getMinutes())}`;
164
+ if (sameDay) return time;
165
+ return `${PAD2(d.getMonth() + 1)}/${PAD2(d.getDate())} ${time}`;
166
+ }
167
+
168
+ export function formatTimeFull(iso: string | undefined | null): string {
169
+ if (!iso) return UNKNOWN_VALUE;
170
+ const d = new Date(iso);
171
+ if (Number.isNaN(d.getTime())) return UNKNOWN_VALUE;
172
+ return (
173
+ `${d.getFullYear()}-${PAD2(d.getMonth() + 1)}-${PAD2(d.getDate())} ` +
174
+ `${PAD2(d.getHours())}:${PAD2(d.getMinutes())}:${PAD2(d.getSeconds())}`
175
+ );
176
+ }
177
+
178
+ export function formatTimeLedger(iso: string | undefined | null): string {
179
+ if (!iso) return UNKNOWN_VALUE;
180
+ const d = new Date(iso);
181
+ if (Number.isNaN(d.getTime())) return UNKNOWN_VALUE;
182
+ return (
183
+ `${d.getFullYear()}/${PAD2(d.getMonth() + 1)}/${PAD2(d.getDate())} ` +
184
+ `${PAD2(d.getHours())}:${PAD2(d.getMinutes())}:${PAD2(d.getSeconds())}`
185
+ );
186
+ }
187
+
188
+ export function formatRouteSwitchTime(iso: string | undefined | null): string {
189
+ return formatTimeCompact(iso);
190
+ }
191
+
192
+ // --- admin-only extensions ---------------------------------------------------
193
+
194
+ export function formatBalanceAmount(
195
+ usdMicros: number | undefined | null,
196
+ currency: string | undefined | null
197
+ ): string {
198
+ if (!Number.isFinite(usdMicros)) return UNKNOWN_VALUE;
199
+ const amount = (usdMicros as number) / 1_000_000;
200
+ const digits = Math.abs(amount) >= 100 ? 0 : 2;
201
+ const code = (currency || "USD").toUpperCase();
202
+ return `${code} ${amount.toFixed(digits)}`;
203
+ }
204
+
205
+ export function formatSellerCapacity(
206
+ used: number | undefined | null,
207
+ limit: number | undefined | null
208
+ ): string {
209
+ if (!Number.isFinite(used) && !Number.isFinite(limit)) return UNKNOWN_VALUE;
210
+ const u = Number.isFinite(used) ? Math.round(used as number) : UNKNOWN_VALUE;
211
+ const l = Number.isFinite(limit) ? Math.round(limit as number) : UNKNOWN_VALUE;
212
+ return `${u} / ${l}`;
213
+ }
214
+
215
+ export function formatSpeed(value: number | undefined | null): string {
216
+ if (!Number.isFinite(value)) return UNKNOWN_VALUE;
217
+ return `${(value as number).toFixed(1)} tok/s`;
218
+ }
219
+
220
+ export type CanonicalStatus =
221
+ | "ok"
222
+ | "online"
223
+ | "configured"
224
+ | "pending"
225
+ | "degraded"
226
+ | "error"
227
+ | "unknown";
228
+
229
+ const SELLER_STATUS_MAP: Record<string, CanonicalStatus> = {
230
+ active: "ok",
231
+ healthy: "ok",
232
+ online: "online",
233
+ configured: "configured",
234
+ pending: "pending",
235
+ draining: "degraded",
236
+ degraded: "degraded",
237
+ busy_capacity: "degraded",
238
+ offline: "error",
239
+ unhealthy: "error",
240
+ error: "error",
241
+ auth_unknown: "unknown",
242
+ unknown: "unknown"
243
+ };
244
+
245
+ export function formatSellerStatus(status: string | undefined | null): CanonicalStatus {
246
+ const key = String(status || "unknown")
247
+ .trim()
248
+ .toLowerCase()
249
+ .replace(/-/g, "_");
250
+ return SELLER_STATUS_MAP[key] || "unknown";
251
+ }
252
+
253
+ export function sellerStatusTone(status: string | undefined | null): StatusTone {
254
+ return statusTone(formatSellerStatus(status));
255
+ }
256
+
257
+ // Inlined browser bundle — emits a self-invoking function that
258
+ // attaches the same formatter API to `window.__tbFmt`. The HTML
259
+ // served by `tb-admin ui` includes this bundle verbatim so the
260
+ // page can use the shared spec-compliant helpers without an
261
+ // extra <script src> round-trip. The JS bodies below MUST stay
262
+ // in lockstep with the TS implementations above.
263
+ export function displayFormatBundle(): string {
264
+ return `(() => {
265
+ const UNKNOWN_VALUE = ${JSON.stringify(UNKNOWN_VALUE)};
266
+ const PAD2 = (n) => (n < 10 ? "0" + n : "" + n);
267
+ function formatTokenCount(value, options) {
268
+ options = options || {};
269
+ if (!Number.isFinite(value)) return UNKNOWN_VALUE;
270
+ const numeric = value;
271
+ if (options.compact === false) return Math.round(numeric).toLocaleString("en-US");
272
+ if (numeric < 10000) return Math.round(numeric).toLocaleString("en-US");
273
+ if (numeric < 1000000) return (numeric / 1000).toFixed(1) + "K";
274
+ if (numeric < 1000000000) return (numeric / 1000000).toFixed(1) + "M";
275
+ return (numeric / 1000000000).toFixed(2) + "B";
276
+ }
277
+ function formatTokenPair(input, output, options) {
278
+ options = options || {};
279
+ const separator = options.separator == null ? " / " : options.separator;
280
+ return "In " + formatTokenCount(input, options) + separator + "Out " + formatTokenCount(output, options);
281
+ }
282
+ function formatCount(value) {
283
+ if (!Number.isFinite(value)) return UNKNOWN_VALUE;
284
+ return Math.round(value).toLocaleString("en-US");
285
+ }
286
+ function formatMoney(micros, options) {
287
+ options = options || {};
288
+ if (!Number.isFinite(micros)) return UNKNOWN_VALUE;
289
+ const amount = Math.abs(micros / 1000000);
290
+ const digits = options.digits != null ? options.digits : (options.ledger && amount < 0.01 ? 6 : 4);
291
+ const formatted = "$" + (micros / 1000000).toFixed(digits);
292
+ return options.signed && micros >= 0 ? "+" + formatted : formatted;
293
+ }
294
+ function formatMoneyPair(actualMicros, referenceMicros, options) {
295
+ options = options || {};
296
+ if (!Number.isFinite(actualMicros) && !Number.isFinite(referenceMicros)) return UNKNOWN_VALUE;
297
+ return formatMoney(actualMicros, options) + " / " + formatMoney(referenceMicros, options);
298
+ }
299
+ function formatDuration(valueMs) {
300
+ if (!Number.isFinite(valueMs)) return UNKNOWN_VALUE;
301
+ const ms = Math.max(0, Math.round(valueMs));
302
+ if (ms < 1000) return ms + "ms";
303
+ return (ms / 1000).toFixed(2) + "s";
304
+ }
305
+ function formatPercent(value) {
306
+ if (!Number.isFinite(value)) return UNKNOWN_VALUE;
307
+ return Math.round(value * 100) + "%";
308
+ }
309
+ function formatDiscountRatio(discountRatio) {
310
+ if (!Number.isFinite(discountRatio)) return UNKNOWN_VALUE;
311
+ const discount = Math.max(0, 1 - discountRatio);
312
+ return formatPercent(discount);
313
+ }
314
+ function formatPriceMicrosPerMillion(value) { return formatMoney(value, { digits: 4 }); }
315
+ function formatPricePair(inputMicros, outputMicros) { return formatMoneyPair(inputMicros, outputMicros, { digits: 4 }); }
316
+ function normalizeStatusLabel(status) {
317
+ if (!status) return UNKNOWN_VALUE;
318
+ return String(status).trim().toLowerCase().replaceAll("_", " ");
319
+ }
320
+ function statusTone(status) {
321
+ const n = normalizeStatusLabel(status);
322
+ if (n === "ok" || n === "online" || n === "configured" || n === "settled" || n === "completed" || n === "success" || n === "active" || n === "healthy") return "green";
323
+ if (n === "fallback" || n === "pending" || n === "degraded" || n === "preview" || n === "draining" || n === "busy capacity" || n === "auth unknown") return "amber";
324
+ if (n === "failed" || n === "error" || n === "canceled" || n === "unhealthy" || n === "offline") return "red";
325
+ if (n === "running") return "blue";
326
+ return "gray";
327
+ }
328
+ function formatHash(value, length) {
329
+ if (length == null) length = 32;
330
+ if (!value) return UNKNOWN_VALUE;
331
+ return value.length > length ? value.slice(0, length) + "..." : value;
332
+ }
333
+ function formatSellerId(value) {
334
+ if (!value) return UNKNOWN_VALUE;
335
+ if (value.startsWith("tbs-") && value.length > 10) return value.slice(0, 10);
336
+ if (value.length <= 12) return value;
337
+ return value.slice(0, 12);
338
+ }
339
+ function formatTimeCompact(iso) {
340
+ if (!iso) return UNKNOWN_VALUE;
341
+ const d = new Date(iso);
342
+ if (Number.isNaN(d.getTime())) return UNKNOWN_VALUE;
343
+ const now = new Date();
344
+ const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
345
+ const time = PAD2(d.getHours()) + ":" + PAD2(d.getMinutes());
346
+ if (sameDay) return time;
347
+ return PAD2(d.getMonth() + 1) + "/" + PAD2(d.getDate()) + " " + time;
348
+ }
349
+ function formatTimeFull(iso) {
350
+ if (!iso) return UNKNOWN_VALUE;
351
+ const d = new Date(iso);
352
+ if (Number.isNaN(d.getTime())) return UNKNOWN_VALUE;
353
+ return d.getFullYear() + "-" + PAD2(d.getMonth() + 1) + "-" + PAD2(d.getDate()) + " " + PAD2(d.getHours()) + ":" + PAD2(d.getMinutes()) + ":" + PAD2(d.getSeconds());
354
+ }
355
+ function formatTimeLedger(iso) {
356
+ if (!iso) return UNKNOWN_VALUE;
357
+ const d = new Date(iso);
358
+ if (Number.isNaN(d.getTime())) return UNKNOWN_VALUE;
359
+ return d.getFullYear() + "/" + PAD2(d.getMonth() + 1) + "/" + PAD2(d.getDate()) + " " + PAD2(d.getHours()) + ":" + PAD2(d.getMinutes()) + ":" + PAD2(d.getSeconds());
360
+ }
361
+ function formatRouteSwitchTime(iso) { return formatTimeCompact(iso); }
362
+ function formatBalanceAmount(usdMicros, currency) {
363
+ if (!Number.isFinite(usdMicros)) return UNKNOWN_VALUE;
364
+ const amount = usdMicros / 1000000;
365
+ const digits = Math.abs(amount) >= 100 ? 0 : 2;
366
+ const code = (currency || "USD").toUpperCase();
367
+ return code + " " + amount.toFixed(digits);
368
+ }
369
+ function formatSellerCapacity(used, limit) {
370
+ if (!Number.isFinite(used) && !Number.isFinite(limit)) return UNKNOWN_VALUE;
371
+ const u = Number.isFinite(used) ? Math.round(used) : UNKNOWN_VALUE;
372
+ const l = Number.isFinite(limit) ? Math.round(limit) : UNKNOWN_VALUE;
373
+ return u + " / " + l;
374
+ }
375
+ function formatSpeed(value) {
376
+ if (!Number.isFinite(value)) return UNKNOWN_VALUE;
377
+ return value.toFixed(1) + " tok/s";
378
+ }
379
+ const SELLER_STATUS_MAP = {
380
+ active: "ok", healthy: "ok", online: "online", configured: "configured",
381
+ pending: "pending", draining: "degraded", degraded: "degraded",
382
+ busy_capacity: "degraded", offline: "error", unhealthy: "error",
383
+ error: "error", auth_unknown: "unknown", unknown: "unknown"
384
+ };
385
+ function formatSellerStatus(status) {
386
+ const key = String(status || "unknown").trim().toLowerCase().replace(/-/g, "_");
387
+ return SELLER_STATUS_MAP[key] || "unknown";
388
+ }
389
+ function sellerStatusTone(status) { return statusTone(formatSellerStatus(status)); }
390
+ window.__tbFmt = {
391
+ UNKNOWN_VALUE, formatTokenCount, formatTokenPair, formatCount, formatMoney, formatMoneyPair,
392
+ formatDuration, formatPercent, formatDiscountRatio, formatPriceMicrosPerMillion, formatPricePair,
393
+ normalizeStatusLabel, statusTone, formatHash, formatSellerId, formatTimeCompact, formatTimeFull,
394
+ formatTimeLedger, formatRouteSwitchTime, formatBalanceAmount, formatSellerCapacity, formatSpeed,
395
+ formatSellerStatus, sellerStatusTone
396
+ };
397
+ })();`;
398
+ }
package/src/server-cmd.ts CHANGED
@@ -1,6 +1,25 @@
1
- import { execSync } from "child_process";
1
+ import { execSync, spawnSync, type SpawnSyncReturns } from "child_process";
2
+ import * as fs from "fs";
2
3
  import { SellerProviderConfig } from "./config.js";
3
4
 
5
+ type ExecRunner = (command: string, options?: Parameters<typeof execSync>[1]) => string | Buffer;
6
+ type SpawnRunner = (command: string, args?: string[], options?: Parameters<typeof spawnSync>[2]) => SpawnSyncReturns<string | Buffer>;
7
+
8
+ export interface DockerImageInspection {
9
+ ok: boolean;
10
+ error?: string;
11
+ exitCode?: number;
12
+ }
13
+
14
+ export type ImageInspectRunner = (image: string) => DockerImageInspection;
15
+
16
+ export interface FlyProviderRuntime {
17
+ checkFlyctlInstalled?: (flyctlPath?: string) => boolean;
18
+ execSync?: ExecRunner;
19
+ spawnSync?: SpawnRunner;
20
+ imageInspector?: ImageInspectRunner;
21
+ }
22
+
4
23
  /**
5
24
  * 检查 flyctl 是否在 PATH 中(或在指定路径)。
6
25
  *
@@ -16,6 +35,41 @@ export function checkFlyctlInstalled(flyctlPath?: string): boolean {
16
35
  }
17
36
  }
18
37
 
38
+ export function inspectDockerImage(image: string): DockerImageInspection {
39
+ const result = spawnSync("docker", ["buildx", "imagetools", "inspect", image], {
40
+ encoding: "utf8",
41
+ stdio: ["ignore", "pipe", "pipe"]
42
+ });
43
+ return dockerImageInspectionFromResult(result);
44
+ }
45
+
46
+ export function requirePublishedDockerImage(image: string, inspectImage: ImageInspectRunner = inspectDockerImage): void {
47
+ const inspection = inspectImage(image);
48
+ if (inspection.ok) {
49
+ return;
50
+ }
51
+ const detail = inspection.error ? ` Detail: ${inspection.error.trim()}` : "";
52
+ throw new Error(
53
+ `seller image is not published or is not accessible: ${image}. ` +
54
+ `Publish it first with RELEASE_VERSION=<v> bash scripts/release/all.sh, or pass an existing registry.fly.io/tb-seller:<v> tag. ` +
55
+ `No Fly app was created.${detail}`
56
+ );
57
+ }
58
+
59
+ function dockerImageInspectionFromResult(result: SpawnSyncReturns<string>): DockerImageInspection {
60
+ if (result.error) {
61
+ return { ok: false, error: result.error.message };
62
+ }
63
+ if (result.status !== 0) {
64
+ return {
65
+ ok: false,
66
+ exitCode: result.status ?? undefined,
67
+ error: (result.stderr || result.stdout || "").toString()
68
+ };
69
+ }
70
+ return { ok: true };
71
+ }
72
+
19
73
  export function parseFlyMachineIds(json: string, app: string): string[] {
20
74
  let parsed: unknown;
21
75
  try {
@@ -60,6 +114,7 @@ export interface SellerCreateOptions {
60
114
  volumeSizeGb?: number;
61
115
  volumeId?: string;
62
116
  volumeSnapshotRetentionDays?: number;
117
+ initialConfigPath?: string;
63
118
  dryRun?: boolean;
64
119
  }
65
120
 
@@ -69,23 +124,57 @@ export interface SellerCreateOptions {
69
124
  */
70
125
  export class FlyProvider {
71
126
  private providerConfig?: SellerProviderConfig;
127
+ private readonly runtime: Required<FlyProviderRuntime>;
72
128
 
73
- constructor(providerConfig?: SellerProviderConfig) {
129
+ constructor(providerConfig?: SellerProviderConfig, runtime?: FlyProviderRuntime) {
74
130
  this.providerConfig = providerConfig;
131
+ this.runtime = {
132
+ checkFlyctlInstalled,
133
+ execSync,
134
+ spawnSync,
135
+ imageInspector: inspectDockerImage,
136
+ ...runtime
137
+ };
75
138
  }
76
139
 
77
140
  private get flyctl(): string {
78
141
  return this.providerConfig?.flyctl_path || "flyctl";
79
142
  }
80
143
 
144
+ private flyExecOptions(options: Parameters<typeof execSync>[1] = {}): Parameters<typeof execSync>[1] {
145
+ return {
146
+ ...options,
147
+ env: this.flyEnv(options.env)
148
+ };
149
+ }
150
+
151
+ private flySpawnOptions(options: Parameters<typeof spawnSync>[2] = {}): Parameters<typeof spawnSync>[2] {
152
+ return {
153
+ ...options,
154
+ env: this.flyEnv(options.env)
155
+ };
156
+ }
157
+
158
+ private flyEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
159
+ const configuredToken = this.providerConfig?.token;
160
+ const merged = {
161
+ ...process.env,
162
+ ...(env || {})
163
+ };
164
+ if (configuredToken && !merged.FLY_API_TOKEN) {
165
+ merged.FLY_API_TOKEN = configuredToken;
166
+ }
167
+ return merged;
168
+ }
169
+
81
170
  /**
82
171
  * List apps on Fly.io
83
172
  */
84
173
  public listApps(): string {
85
- if (!checkFlyctlInstalled(this.flyctl)) {
174
+ if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
86
175
  throw new Error(`\`${this.flyctl}\` is not installed on your system PATH.`);
87
176
  }
88
- return execSync(`${this.flyctl} apps list`, { encoding: "utf8" });
177
+ return this.runtime.execSync(`${this.flyctl} apps list`, this.flyExecOptions({ encoding: "utf8" })) as string;
89
178
  }
90
179
 
91
180
  /**
@@ -112,6 +201,7 @@ export class FlyProvider {
112
201
  || 1;
113
202
  const volumeSnapshotRetentionDays = options.volumeSnapshotRetentionDays;
114
203
  const volumeId = options.volumeId;
204
+ const initialConfigPath = options.initialConfigPath;
115
205
 
116
206
  if (!targetImage) {
117
207
  throw new Error("seller create requires --image registry.fly.io/tb-seller:<v>");
@@ -129,35 +219,59 @@ export class FlyProvider {
129
219
  ` Volume: ${volumeName} (${volumeSizeGb}GB)`,
130
220
  ];
131
221
  if (flyConfig) lines.push(` Fly config: ${flyConfig}`);
222
+ if (initialConfigPath) lines.push(` Initial config secret: TOKENBUDDY_SELLER_CONFIG_B64 from ${initialConfigPath}`);
132
223
  if (volumeId) lines.push(` Volume ID: ${volumeId}`);
133
224
  if (volumeSnapshotRetentionDays !== undefined) lines.push(` Volume snapshot retention: ${volumeSnapshotRetentionDays} days`);
134
225
  return lines.join("\n");
135
226
  }
136
227
 
137
- if (!checkFlyctlInstalled(this.flyctl)) {
228
+ if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
138
229
  throw new Error(`\`${this.flyctl}\` is not installed on PATH.`);
139
230
  }
140
231
 
141
232
  if (!operatorSecret) {
142
233
  throw new Error("operator_secret is required. Provide --operator-secret or configure seller_providers.fly.operator_secret");
143
234
  }
235
+ requireReadableFile(flyConfig, "Fly config");
236
+ if (initialConfigPath) {
237
+ requireReadableFile(initialConfigPath, "Initial seller config");
238
+ }
239
+ requirePublishedDockerImage(targetImage, this.runtime.imageInspector);
144
240
 
145
241
  console.log(`[Fly.io] Creating app ${appName}...`);
146
- execSync(`${this.flyctl} apps create ${appName} --machines`, { stdio: "inherit" });
242
+ this.runtime.execSync(`${this.flyctl} apps create ${appName} --machines`, this.flyExecOptions({ stdio: "inherit" }));
147
243
 
148
244
  console.log(`[Fly.io] Setting secrets...`);
149
- execSync(
150
- `${this.flyctl} secrets set ALLOW_MOCK=false OPERATOR_SECRET=${operatorSecret} --app ${appName}`,
151
- { stdio: "inherit" }
152
- );
245
+ this.importCreateSecrets(appName, operatorSecret, initialConfigPath);
153
246
 
154
247
  console.log(`[Fly.io] Deploying image ${targetImage}...`);
155
- const deployCmd = `${this.flyctl} deploy -c ${flyConfig} --image ${targetImage} --region ${targetRegion} --app ${appName} --now`;
156
- execSync(deployCmd, { stdio: "inherit" });
248
+ const deployCmd = `${this.flyctl} deploy -c ${flyConfig} --image ${targetImage} --primary-region ${targetRegion} --app ${appName} --now`;
249
+ this.runtime.execSync(deployCmd, this.flyExecOptions({ stdio: "inherit" }));
157
250
 
158
251
  return `Successfully deployed ${appName} on Fly.io`;
159
252
  }
160
253
 
254
+ private importCreateSecrets(appName: string, operatorSecret: string, initialConfigPath: string | undefined): void {
255
+ const lines = [
256
+ "ALLOW_MOCK=false",
257
+ `OPERATOR_SECRET=${operatorSecret}`
258
+ ];
259
+ if (initialConfigPath) {
260
+ const configContent = fs.readFileSync(initialConfigPath, "utf8");
261
+ lines.push(`TOKENBUDDY_SELLER_CONFIG_B64=${Buffer.from(configContent, "utf8").toString("base64")}`);
262
+ }
263
+ const result = this.runtime.spawnSync(this.flyctl, ["secrets", "import", "--stage", "--app", appName], this.flySpawnOptions({
264
+ input: `${lines.join("\n")}\n`,
265
+ stdio: ["pipe", "inherit", "inherit"]
266
+ }));
267
+ if (result.error) {
268
+ throw result.error;
269
+ }
270
+ if (result.status !== 0) {
271
+ throw new Error(`flyctl secrets import failed with exit code ${result.status}`);
272
+ }
273
+ }
274
+
161
275
  /**
162
276
  * Destroy a seller app on Fly.io.
163
277
  * @param nameOrApp Either a bare name (e.g. "86d81e") or a full app name (e.g. "tbs-86d81e").
@@ -170,12 +284,12 @@ export class FlyProvider {
170
284
  return `[DRY-RUN] Will destroy fly app: ${appName}`;
171
285
  }
172
286
 
173
- if (!checkFlyctlInstalled(this.flyctl)) {
287
+ if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
174
288
  throw new Error(`\`${this.flyctl}\` is not installed.`);
175
289
  }
176
290
 
177
291
  console.log(`[Fly.io] Destroying app ${appName}...`);
178
- execSync(`${this.flyctl} apps destroy ${appName} --yes`, { stdio: "inherit" });
292
+ this.runtime.execSync(`${this.flyctl} apps destroy ${appName} --yes`, this.flyExecOptions({ stdio: "inherit" }));
179
293
 
180
294
  return `Successfully destroyed ${appName} on Fly.io`;
181
295
  }
@@ -185,10 +299,10 @@ export class FlyProvider {
185
299
  */
186
300
  public statusApp(name: string): string {
187
301
  const appName = name.includes("-") ? name : `tb-seller-${name}`;
188
- if (!checkFlyctlInstalled(this.flyctl)) {
302
+ if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
189
303
  throw new Error(`\`${this.flyctl}\` is not installed.`);
190
304
  }
191
- return execSync(`${this.flyctl} status --app ${appName}`, { encoding: "utf8" });
305
+ return this.runtime.execSync(`${this.flyctl} status --app ${appName}`, this.flyExecOptions({ encoding: "utf8" })) as string;
192
306
  }
193
307
 
194
308
  /**
@@ -218,20 +332,31 @@ export class FlyProvider {
218
332
  return lines.join("\n");
219
333
  }
220
334
 
221
- if (!checkFlyctlInstalled(this.flyctl)) {
335
+ if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
222
336
  throw new Error(`\`${this.flyctl}\` is not installed.`);
223
337
  }
224
338
 
225
- const machinesJson = execSync(`${this.flyctl} machines list --app ${app} --json`, { encoding: "utf8" });
339
+ const machinesJson = this.runtime.execSync(`${this.flyctl} machines list --app ${app} --json`, this.flyExecOptions({ encoding: "utf8" })) as string;
226
340
  const machineIds = parseFlyMachineIds(machinesJson, app);
227
341
 
228
342
  console.log(`[Fly.io] Updating ${app} image on ${machineIds.length} machine(s)...`);
229
343
  for (const machineId of machineIds) {
230
- execSync(
344
+ this.runtime.execSync(
231
345
  `${this.flyctl} machine update ${machineId} --app ${app} --image ${targetImage} --yes`,
232
- { stdio: "inherit" }
346
+ this.flyExecOptions({ stdio: "inherit" })
233
347
  );
234
348
  }
235
349
  return `Successfully updated ${app} image`;
236
350
  }
237
351
  }
352
+
353
+ function requireReadableFile(filePath: string, label: string): void {
354
+ if (!fs.existsSync(filePath)) {
355
+ throw new Error(`${label} file does not exist: ${filePath}`);
356
+ }
357
+ const stat = fs.statSync(filePath);
358
+ if (!stat.isFile()) {
359
+ throw new Error(`${label} path is not a file: ${filePath}`);
360
+ }
361
+ fs.accessSync(filePath, fs.constants.R_OK);
362
+ }