@tokenbuddy/tb-admin 1.0.32 → 1.0.34

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 (46) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +29 -1
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/client.js +3 -3
  5. package/dist/src/client.js.map +1 -1
  6. package/dist/src/seller.d.ts +40 -1
  7. package/dist/src/seller.d.ts.map +1 -1
  8. package/dist/src/seller.js +132 -2
  9. package/dist/src/seller.js.map +1 -1
  10. package/dist/src/ui-actions.d.ts +2 -0
  11. package/dist/src/ui-actions.d.ts.map +1 -1
  12. package/dist/src/ui-actions.js +8 -6
  13. package/dist/src/ui-actions.js.map +1 -1
  14. package/dist/src/ui-command.d.ts +1 -0
  15. package/dist/src/ui-command.d.ts.map +1 -1
  16. package/dist/src/ui-command.js +7 -2
  17. package/dist/src/ui-command.js.map +1 -1
  18. package/dist/src/ui-server.d.ts.map +1 -1
  19. package/dist/src/ui-server.js +29 -8
  20. package/dist/src/ui-server.js.map +1 -1
  21. package/dist/src/ui-state.d.ts +29 -0
  22. package/dist/src/ui-state.d.ts.map +1 -1
  23. package/dist/src/ui-state.js +455 -111
  24. package/dist/src/ui-state.js.map +1 -1
  25. package/dist/src/ui-static.d.ts.map +1 -1
  26. package/dist/src/ui-static.js +262 -143
  27. package/dist/src/ui-static.js.map +1 -1
  28. package/dist/src/upstream-balance-probe.d.ts +2 -40
  29. package/dist/src/upstream-balance-probe.d.ts.map +1 -1
  30. package/dist/src/upstream-balance-probe.js +1 -378
  31. package/dist/src/upstream-balance-probe.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/cli.ts +32 -1
  34. package/src/client.ts +3 -4
  35. package/src/seller.ts +179 -3
  36. package/src/ui-actions.ts +10 -6
  37. package/src/ui-command.ts +7 -2
  38. package/src/ui-server.ts +30 -8
  39. package/src/ui-state.ts +533 -111
  40. package/src/ui-static.ts +262 -143
  41. package/src/upstream-balance-probe.ts +13 -505
  42. package/tests/admin.test.ts +472 -36
  43. package/tests/seller.test.ts +84 -3
  44. package/tests/ui-state-fleet.test.ts +272 -3
  45. package/tests/ui-static-row.test.ts +273 -8
  46. package/tests/vendor-cli.test.ts +45 -1
@@ -20,7 +20,8 @@
20
20
  */
21
21
 
22
22
  import { execFileSync } from "node:child_process";
23
- import { existsSync } from "node:fs";
23
+ import { existsSync, mkdtempSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
24
25
  import { join } from "node:path";
25
26
  import { ConfigManager } from "../src/config.js";
26
27
  import { SellerCommandRunner, parseFlyListJson, parseFlyStatusJson } from "../src/seller.js";
@@ -28,16 +29,28 @@ import { FlyProvider, type FlyProviderRuntime } from "../src/server-cmd.js";
28
29
 
29
30
  const TB_ADMIN_BIN = join(__dirname, "..", "bin", "tb-admin.js");
30
31
  const FLYCTL = "flyctl";
32
+ const RUN_LIVE_FLY_TESTS = process.env.TOKENBUDDY_ADMIN_LIVE_FLY_TESTS === "1";
31
33
 
32
34
  function flyctlInstalled(): boolean {
33
35
  try {
34
- execFileSync(`which ${FLYCTL}`, { stdio: "ignore" });
36
+ execFileSync("which", [FLYCTL], { stdio: "ignore" });
35
37
  return true;
36
38
  } catch {
37
39
  return false;
38
40
  }
39
41
  }
40
42
 
43
+ function makeEmptyConfigManager(): ConfigManager {
44
+ const dir = mkdtempSync(join(tmpdir(), "tb-admin-test-"));
45
+ return new ConfigManager(join(dir, "admin.toml"));
46
+ }
47
+
48
+ function makeMissingFlyctlConfigManager(): ConfigManager {
49
+ const manager = makeEmptyConfigManager();
50
+ manager.setSellerProvider("fly", { flyctl_path: "/tmp/tokenbuddy-missing-flyctl" });
51
+ return manager;
52
+ }
53
+
41
54
  function tbAdminBinInstalled(): boolean {
42
55
  return existsSync(TB_ADMIN_BIN);
43
56
  }
@@ -98,6 +111,10 @@ describe("tb-admin seller CLI real spawn (no mock)", () => {
98
111
  // 每个 test 进来时现场探测.
99
112
  const itRequires = (name: string, fn: () => void | Promise<void>) => {
100
113
  test(name, async () => {
114
+ if (!RUN_LIVE_FLY_TESTS) {
115
+ console.warn(`[skipped] ${name}: set TOKENBUDDY_ADMIN_LIVE_FLY_TESTS=1 to run live Fly.io checks`);
116
+ return;
117
+ }
101
118
  const hasFlyctl = flyctlInstalled();
102
119
  const hasBin = tbAdminBinInstalled();
103
120
  if (!hasFlyctl || !hasBin) {
@@ -205,6 +222,57 @@ describe("tb-admin seller CLI real spawn (no mock)", () => {
205
222
  });
206
223
  expect(parsed.commands[0]).toContain("apps destroy tbs-86d81e");
207
224
  });
225
+
226
+ itRequires("roll --dry-run text path lists all live tbs-* candidates from fly apps list", () => {
227
+ const result = runTbAdminReal([
228
+ "seller", "roll",
229
+ "--image", "registry.fly.io/tb-seller:1.0.33",
230
+ "--dry-run"
231
+ ]);
232
+ expect(result.ok).toBe(true);
233
+ expect(result.stdout).toMatch(/\[Fly\.io\] roll candidates \(tbs-\*, excludes applied\): \d+/);
234
+ // 至少要有 tbs-86d81e (live 1.0.31 已知)
235
+ expect(result.stdout).toMatch(/tbs-86d81e/);
236
+ // dry-run 模式: 每台都是 [DRY-RUN] 而非真 flyctl
237
+ expect(result.stdout).toMatch(/\[DRY-RUN\] would update tbs-86d81e image to/);
238
+ });
239
+
240
+ itRequires("roll --dry-run --json returns structured candidates/excluded/attempts", () => {
241
+ const result = runTbAdminReal([
242
+ "seller", "roll",
243
+ "--image", "registry.fly.io/tb-seller:1.0.33",
244
+ "--exclude", "tbs-anpin-ai-0d7517,tbs-openrouter-ai-06vry",
245
+ "--dry-run", "--json"
246
+ ]);
247
+ expect(result.ok).toBe(true);
248
+ const parsed = JSON.parse(result.stdout);
249
+ expect(parsed).toMatchObject({
250
+ ok: true,
251
+ provider: "fly",
252
+ action: "roll",
253
+ image: "registry.fly.io/tb-seller:1.0.33",
254
+ dryRun: true,
255
+ completed: true
256
+ });
257
+ expect(Array.isArray(parsed.candidates)).toBe(true);
258
+ expect(parsed.candidates).toContain("tbs-86d81e");
259
+ expect(parsed.candidates).not.toContain("tbs-anpin-ai-0d7517");
260
+ expect(parsed.excluded).toEqual(expect.arrayContaining(["tbs-anpin-ai-0d7517", "tbs-openrouter-ai-06vry"]));
261
+ // attempts 顺序应该跟 candidates 顺序一致
262
+ expect(parsed.attempts.length).toBe(parsed.candidates.length);
263
+ for (let i = 0; i < parsed.attempts.length; i++) {
264
+ expect(parsed.attempts[i].app).toBe(parsed.candidates[i]);
265
+ expect(parsed.attempts[i].ok).toBe(true);
266
+ }
267
+ });
268
+
269
+ itRequires("roll rejects empty image and returns 1 (with usage error)", () => {
270
+ const result = runTbAdminReal([
271
+ "seller", "roll", "--dry-run", "--json"
272
+ ]);
273
+ // commander requiredOption 失败时, 进程退出非 0
274
+ expect(result.ok).toBe(false);
275
+ });
208
276
  });
209
277
 
210
278
  describe("SellerCommandRunner integration with real ConfigManager (no mock)", () => {
@@ -219,10 +287,15 @@ describe("SellerCommandRunner integration with real ConfigManager (no mock)", ()
219
287
  }
220
288
 
221
289
  test("ls(false) returns flyctl stdout string via real FlyProvider (no mock)", () => {
290
+ if (!RUN_LIVE_FLY_TESTS) {
291
+ const runner = new SellerCommandRunner(makeMissingFlyctlConfigManager());
292
+ expect(() => runner.ls(false)).toThrow(/flyctl|not installed|seller_providers/);
293
+ return;
294
+ }
222
295
  const mgr = tryRealConfigManager();
223
296
  if (!mgr) {
224
297
  // 环境无 admin profile, 用空 ConfigManager 走 fallback
225
- const tmp = new ConfigManager("/tmp/tb-admin-no-config-" + Date.now() + ".toml");
298
+ const tmp = makeEmptyConfigManager();
226
299
  const runner = new SellerCommandRunner(tmp);
227
300
  // 没有 provider config -> 走 FlyProvider -> flyctl not installed (test env) -> throw
228
301
  try {
@@ -245,6 +318,14 @@ describe("FlyProvider dry-run paths (no mock, real flyctl required)", () => {
245
318
  // 只输出 plan. 这是 FlyProvider 的核心契约.
246
319
 
247
320
  function makeRealProvider(): FlyProvider | null {
321
+ if (!RUN_LIVE_FLY_TESTS) {
322
+ return new FlyProvider({
323
+ default_region: "sin",
324
+ operator_secret: "test-secret",
325
+ volume_name: "tb_seller_data",
326
+ volume_size_gb: 1
327
+ });
328
+ }
248
329
  const home = process.env.HOME || "";
249
330
  const adminToml = join(home, ".config", "tokenbuddy", "admin.toml");
250
331
  if (!existsSync(adminToml)) {
@@ -40,22 +40,44 @@ interface FakeServerHandle {
40
40
  url: string;
41
41
  setManifest: (sellerId: string, resp: FakeManifestResponse) => void;
42
42
  setManifestPath: (path: string, resp: FakeManifestResponse) => void;
43
+ setOperatorJson: (path: string, resp: unknown) => void;
43
44
  setRegistry: (resp: { sellers: any[]; version: number }) => void;
45
+ setManagedSellers: (sellers: any[]) => void;
46
+ requests: () => Array<{ path: string; authorization?: string }>;
44
47
  close: () => Promise<void>;
45
48
  }
46
49
 
47
50
  function startFakeRegistry(): Promise<FakeServerHandle> {
48
51
  let registryDoc: { sellers: any[]; version: number } = { sellers: [], version: 0 };
52
+ let managedSellers: any[] | undefined;
49
53
  const manifests = new Map<string, FakeManifestResponse>();
54
+ const operatorJson = new Map<string, unknown>();
55
+ const requests: Array<{ path: string; authorization?: string }> = [];
50
56
 
51
57
  return new Promise((resolve) => {
52
58
  const server = createServer((req, res) => {
53
59
  const url = new URL(req.url || "/", "http://localhost");
60
+ requests.push({ path: url.pathname, authorization: req.headers.authorization });
61
+ if (url.pathname === "/platform/sellers") {
62
+ if (req.headers.authorization !== "Bearer fake-vendor-token") {
63
+ res.writeHead(401, { "Content-Type": "application/json" });
64
+ res.end(JSON.stringify({ error: "vendor_auth_required" }));
65
+ return;
66
+ }
67
+ res.writeHead(200, { "Content-Type": "application/json" });
68
+ res.end(JSON.stringify({ sellers: managedSellers ?? registryDoc.sellers }));
69
+ return;
70
+ }
54
71
  if (url.pathname === "/registry/sellers") {
55
72
  res.writeHead(200, { "Content-Type": "application/json" });
56
73
  res.end(JSON.stringify(registryDoc));
57
74
  return;
58
75
  }
76
+ if (operatorJson.has(url.pathname)) {
77
+ res.writeHead(200, { "Content-Type": "application/json" });
78
+ res.end(JSON.stringify(operatorJson.get(url.pathname)));
79
+ return;
80
+ }
59
81
  // 1) 按 path 查 (setManifestPath 注册的)
60
82
  if (manifests.has(url.pathname)) {
61
83
  const resp = manifests.get(url.pathname)!;
@@ -85,9 +107,16 @@ function startFakeRegistry(): Promise<FakeServerHandle> {
85
107
  setManifestPath: (path, resp) => {
86
108
  manifests.set(path, resp);
87
109
  },
110
+ setOperatorJson: (path, resp) => {
111
+ operatorJson.set(path, resp);
112
+ },
88
113
  setRegistry: (doc) => {
89
114
  registryDoc = doc;
90
115
  },
116
+ setManagedSellers: (sellers) => {
117
+ managedSellers = sellers;
118
+ },
119
+ requests: () => requests.slice(),
91
120
  close: () => new Promise<void>((r) => server.close(() => r()))
92
121
  };
93
122
  resolve(handle);
@@ -100,7 +129,8 @@ let tmpConfigPath: string;
100
129
 
101
130
  function makeState(
102
131
  registryUrl: string,
103
- flyApps: Omit<SellerAppJson, "raw">[]
132
+ flyApps: Omit<SellerAppJson, "raw">[],
133
+ token = "fake-vendor-token"
104
134
  ): AdminUiState {
105
135
  if (!tmpDir) {
106
136
  tmpDir = mkdtempSync(join(tmpdir(), "ui-fleet-test-"));
@@ -110,7 +140,7 @@ function makeState(
110
140
  tmpConfigPath,
111
141
  `[profiles.default]
112
142
  url = "${registryUrl}"
113
- token = "fake-vendor-token"
143
+ token = "${token}"
114
144
  `,
115
145
  "utf8"
116
146
  );
@@ -119,10 +149,13 @@ token = "fake-vendor-token"
119
149
  configManager,
120
150
  profile: "default",
121
151
  url: registryUrl,
122
- token: "fake-vendor-token",
152
+ token,
123
153
  flyApps: async () => flyApps.map((a) => ({ ...a, raw: {} })),
124
154
  fetchJson: (async (target: string, init?: RequestInit) => {
125
155
  const res = await fetch(target, init);
156
+ if (!res.ok) {
157
+ throw new Error(`HTTP Error ${res.status}: ${await res.text()}`);
158
+ }
126
159
  return await res.json();
127
160
  }) as any
128
161
  });
@@ -174,6 +207,242 @@ describe("AdminUiState 双源 seller list (v1.1 spec)", () => {
174
207
  expect(rows[0].registryAlert).toBeFalsy();
175
208
  // 绿点: /manifest 200 → nodeStatus=active (manifest ok 映射 active)
176
209
  expect(rows[0].nodeStatus).toBe("active");
210
+ expect(fake.requests()).toContainEqual({
211
+ path: "/platform/sellers",
212
+ authorization: "Bearer fake-vendor-token"
213
+ });
214
+ });
215
+
216
+ test("sellerRegistryRows returns registry publish-checking rows before Fly inventory", async () => {
217
+ fake.setRegistry({
218
+ version: 7,
219
+ sellers: [
220
+ {
221
+ id: "alpha",
222
+ name: "Alpha Seller",
223
+ app: "tb-seller-alpha",
224
+ url: `${fake.url}/alpha-base`,
225
+ status: "active",
226
+ supportedProtocols: ["chat_completions"],
227
+ paymentMethods: ["clawtip"],
228
+ models: ["gpt-5.4"]
229
+ }
230
+ ]
231
+ });
232
+ const state = makeState(fake.url, [
233
+ { name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
234
+ ]);
235
+
236
+ const rows = await state.sellerRegistryRows();
237
+ expect(rows).toHaveLength(1);
238
+ expect(rows[0]).toMatchObject({
239
+ id: "alpha",
240
+ publishStatus: "checking",
241
+ detailStatus: "pending",
242
+ nodeStatus: "unknown"
243
+ });
244
+ expect(fake.requests().some((request) => request.path.includes("/manifest"))).toBe(false);
245
+ });
246
+
247
+ test("sellerInventory merges registry + Fly without probing seller details", async () => {
248
+ fake.setRegistry({
249
+ version: 7,
250
+ sellers: [
251
+ {
252
+ id: "alpha",
253
+ name: "Alpha Seller",
254
+ app: "tb-seller-alpha",
255
+ url: `${fake.url}/alpha-base`,
256
+ status: "active",
257
+ supportedProtocols: ["chat_completions"],
258
+ paymentMethods: ["clawtip"],
259
+ models: ["gpt-5.4"]
260
+ }
261
+ ]
262
+ });
263
+ fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
264
+ const state = makeState(fake.url, [
265
+ { name: "tb-seller-alpha", status: "running", owner: "vendor-a" },
266
+ { name: "tb-seller-flyonly", status: "running", owner: "vendor-a" }
267
+ ]);
268
+
269
+ const rows = await state.sellerInventory();
270
+ expect(rows).toHaveLength(2);
271
+ expect(rows.find((row) => row.id === "alpha")).toMatchObject({
272
+ dataSource: "both",
273
+ publishStatus: "published",
274
+ detailStatus: "pending",
275
+ nodeStatus: "unknown"
276
+ });
277
+ expect(rows.find((row) => row.id === "tb-seller-flyonly")).toMatchObject({
278
+ dataSource: "fly",
279
+ publishStatus: "unpublished",
280
+ detailStatus: "pending"
281
+ });
282
+ expect(fake.requests().some((request) => request.path.includes("/manifest"))).toBe(false);
283
+ });
284
+
285
+ test("sellerInventory filters non-seller Fly apps", async () => {
286
+ fake.setRegistry({ version: 7, sellers: [] });
287
+ const state = makeState(fake.url, [
288
+ { name: "tb-registry", status: "running", owner: "vendor-a" },
289
+ { name: "tb-seller", status: "running", owner: "vendor-a" },
290
+ { name: "tbs-valid", status: "running", owner: "vendor-a" }
291
+ ]);
292
+
293
+ const rows = await state.sellerInventory();
294
+ expect(rows.map((row) => row.app)).toEqual(["tbs-valid"]);
295
+ });
296
+
297
+ test("refreshSellerRows hydrates upstream domain from seller config", async () => {
298
+ fake.setManifestPath("/manifest", { status: 200, body: { ok: true } });
299
+ fake.setOperatorJson("/operator/status", {
300
+ status: "healthy",
301
+ upstream: { status: "healthy" },
302
+ capacity: { activeConnections: 2, maxConnections: 8 },
303
+ runtime: { cpuPercent: 12.5, memoryPercent: 48, memoryRssMb: 246, memoryLimitMb: 512 }
304
+ });
305
+ fake.setOperatorJson("/operator/admin/upstreams", {
306
+ upstreams: [{
307
+ upstreamUrl: "https://openrouter.ai/api/v1",
308
+ discountRatio: 1,
309
+ models: [{ id: "openai/gpt-5.4" }]
310
+ }]
311
+ });
312
+ fake.setOperatorJson("/operator/admin/config", {
313
+ config: {
314
+ upstreamUrl: "https://openrouter.ai/api/v1",
315
+ discountRatio: 1
316
+ }
317
+ });
318
+ const state = makeState(fake.url, []);
319
+
320
+ const rows = await state.refreshSellerRows([{
321
+ id: "tbs-openrouter",
322
+ name: "tbs-openrouter",
323
+ app: "tbs-openrouter",
324
+ url: fake.url,
325
+ registryStatus: "active",
326
+ nodeStatus: "unknown",
327
+ upstreamDomain: "tbs-openrouter.fly.dev",
328
+ upstreamStatus: "unknown",
329
+ dataSource: "both",
330
+ publishStatus: "published",
331
+ detailStatus: "queued"
332
+ }]);
333
+
334
+ expect(rows[0]).toMatchObject({
335
+ upstreamDomain: "openrouter.ai",
336
+ upstreamStatus: "healthy",
337
+ capacityUsed: 2,
338
+ capacityLimit: 8,
339
+ resourceCpuPercent: 12.5,
340
+ resourceMemoryPercent: 48,
341
+ modelsCount: 1,
342
+ detailStatus: "fresh"
343
+ });
344
+ });
345
+
346
+ test("case 1b: empty vendor-managed list keeps public registry publish state", async () => {
347
+ fake.setRegistry({
348
+ version: 9,
349
+ sellers: [
350
+ {
351
+ id: "public-alpha",
352
+ name: "Public Alpha",
353
+ app: "tb-seller-alpha",
354
+ url: `${fake.url}/alpha-base`,
355
+ status: "active",
356
+ supportedProtocols: ["chat_completions"],
357
+ paymentMethods: ["clawtip"],
358
+ models: ["gpt-5.4"]
359
+ }
360
+ ]
361
+ });
362
+ fake.setManagedSellers([]);
363
+ fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
364
+ const state = makeState(fake.url, [
365
+ { name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
366
+ ]);
367
+
368
+ const rows = await state.sellers();
369
+ expect(rows).toHaveLength(1);
370
+ expect(rows[0]).toMatchObject({
371
+ id: "public-alpha",
372
+ dataSource: "both",
373
+ registryStatus: "active",
374
+ nodeStatus: "active"
375
+ });
376
+ expect(rows[0].publishHint).toBeUndefined();
377
+ });
378
+
379
+ test("case 1c: vendor-managed auth failure falls back to public registry", async () => {
380
+ fake.setRegistry({
381
+ version: 9,
382
+ sellers: [
383
+ {
384
+ id: "public-alpha",
385
+ name: "Public Alpha",
386
+ app: "tb-seller-alpha",
387
+ url: `${fake.url}/alpha-base`,
388
+ status: "active",
389
+ supportedProtocols: ["chat_completions"],
390
+ paymentMethods: ["clawtip"],
391
+ models: ["gpt-5.4"]
392
+ }
393
+ ]
394
+ });
395
+ fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
396
+ const state = makeState(fake.url, [
397
+ { name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
398
+ ], "wrong-vendor-token");
399
+
400
+ const rows = await state.sellers();
401
+ expect(rows).toHaveLength(1);
402
+ expect(rows[0]).toMatchObject({
403
+ id: "public-alpha",
404
+ dataSource: "both",
405
+ registryStatus: "active",
406
+ nodeStatus: "active"
407
+ });
408
+ expect(fake.requests()).toContainEqual({
409
+ path: "/platform/sellers",
410
+ authorization: "Bearer wrong-vendor-token"
411
+ });
412
+ expect(fake.requests()).toContainEqual({
413
+ path: "/registry/sellers",
414
+ authorization: undefined
415
+ });
416
+ });
417
+
418
+ test("case 1d: registry entry without app matches Fly app by id", async () => {
419
+ fake.setRegistry({
420
+ version: 9,
421
+ sellers: [
422
+ {
423
+ id: "tb-seller-alpha",
424
+ name: "Alpha Seller",
425
+ url: `${fake.url}/alpha-base`,
426
+ status: "active",
427
+ supportedProtocols: ["chat_completions"],
428
+ paymentMethods: ["clawtip"],
429
+ models: ["gpt-5.4"]
430
+ }
431
+ ]
432
+ });
433
+ fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
434
+ const state = makeState(fake.url, [
435
+ { name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
436
+ ]);
437
+
438
+ const rows = await state.sellers();
439
+ expect(rows).toHaveLength(1);
440
+ expect(rows[0]).toMatchObject({
441
+ id: "tb-seller-alpha",
442
+ dataSource: "both",
443
+ registryStatus: "active",
444
+ nodeStatus: "active"
445
+ });
177
446
  });
178
447
 
179
448
  test("case 2: fly only → dataSource=fly, 灰点, registryAlert=undefined", async () => {