@tokenbuddy/tb-admin 1.0.30 → 1.0.32

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 (52) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +280 -19
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/client.d.ts +82 -2
  5. package/dist/src/client.d.ts.map +1 -1
  6. package/dist/src/client.js +93 -0
  7. package/dist/src/client.js.map +1 -1
  8. package/dist/src/provider.d.ts +120 -0
  9. package/dist/src/provider.d.ts.map +1 -0
  10. package/dist/src/provider.js +73 -0
  11. package/dist/src/provider.js.map +1 -0
  12. package/dist/src/seller.d.ts +104 -0
  13. package/dist/src/seller.d.ts.map +1 -0
  14. package/dist/src/seller.js +283 -0
  15. package/dist/src/seller.js.map +1 -0
  16. package/dist/src/ui-actions.d.ts +25 -0
  17. package/dist/src/ui-actions.d.ts.map +1 -1
  18. package/dist/src/ui-actions.js +81 -11
  19. package/dist/src/ui-actions.js.map +1 -1
  20. package/dist/src/ui-server.js +9 -0
  21. package/dist/src/ui-server.js.map +1 -1
  22. package/dist/src/ui-state.d.ts +77 -2
  23. package/dist/src/ui-state.d.ts.map +1 -1
  24. package/dist/src/ui-state.js +242 -14
  25. package/dist/src/ui-state.js.map +1 -1
  26. package/dist/src/ui-static.d.ts.map +1 -1
  27. package/dist/src/ui-static.js +95 -17
  28. package/dist/src/ui-static.js.map +1 -1
  29. package/dist/src/vendor-client.d.ts +23 -0
  30. package/dist/src/vendor-client.d.ts.map +1 -0
  31. package/dist/src/vendor-client.js +2 -0
  32. package/dist/src/vendor-client.js.map +1 -0
  33. package/dist/src/vendor-commands.d.ts +35 -0
  34. package/dist/src/vendor-commands.d.ts.map +1 -0
  35. package/dist/src/vendor-commands.js +33 -0
  36. package/dist/src/vendor-commands.js.map +1 -0
  37. package/package.json +1 -1
  38. package/src/cli.ts +305 -31
  39. package/src/client.ts +119 -2
  40. package/src/provider.ts +150 -0
  41. package/src/seller.ts +362 -0
  42. package/src/ui-actions.ts +89 -11
  43. package/src/ui-server.ts +9 -0
  44. package/src/ui-state.ts +293 -15
  45. package/src/ui-static.ts +95 -17
  46. package/src/vendor-client.ts +23 -0
  47. package/src/vendor-commands.ts +65 -0
  48. package/tests/admin.test.ts +20 -1
  49. package/tests/seller.test.ts +307 -0
  50. package/tests/ui-state-fleet.test.ts +257 -0
  51. package/tests/ui-static-row.test.ts +202 -0
  52. package/tests/vendor-cli.test.ts +197 -0
@@ -0,0 +1,65 @@
1
+ import * as fs from "fs";
2
+ import { RegistryVendorClient, SellerRegistryEntry } from "./vendor-client.js";
3
+
4
+ /**
5
+ * vendor CLI helpers for the `bootstrap sellers` and
6
+ * `bootstrap release` subcommands. Step 5 of the registry redesign.
7
+ *
8
+ * - `bootstrap sellers add --file <path>`: stage a single seller
9
+ * for inclusion in the next release request submitted by this
10
+ * vendor. The server validates the payload shape, persists it to
11
+ * `seller_pending`, and returns a `pendingSeller` row.
12
+ * - `bootstrap release submit --note <text> --staged <id>`: submit
13
+ * a release request that includes one or more staged sellers.
14
+ * - `bootstrap release list`: list the vendor's release requests.
15
+ * - `bootstrap release show <id>`: show one release request.
16
+ * - `bootstrap release force-publish <id>`: super-admin path; the
17
+ * vendor token cannot force-publish itself (the platform owns
18
+ * the scheduler loop in Step 7), but we keep the command here
19
+ * so CI scripts have a single entry point.
20
+ */
21
+
22
+ export interface StageSellerInput {
23
+ client: RegistryVendorClient;
24
+ file: string;
25
+ }
26
+
27
+ export async function stageSellerFromFile({ client, file }: StageSellerInput): Promise<{ pendingSeller: unknown }> {
28
+ const content = fs.readFileSync(file, "utf8");
29
+ const parsed = JSON.parse(content) as SellerRegistryEntry;
30
+ if (!parsed.id || typeof parsed.id !== "string") {
31
+ throw new Error(`seller id is required in ${file}`);
32
+ }
33
+ return client.stageSeller(parsed);
34
+ }
35
+
36
+ export interface SubmitReleaseInput {
37
+ client: RegistryVendorClient;
38
+ stagedSellerIds: string[];
39
+ note?: string;
40
+ }
41
+
42
+ export async function submitRelease({ client, stagedSellerIds, note }: SubmitReleaseInput): Promise<{ releaseRequest: unknown }> {
43
+ if (stagedSellerIds.length === 0) {
44
+ throw new Error("at least one --staged <seller-id> is required");
45
+ }
46
+ return client.submitRelease({ stagedSellerIds, note });
47
+ }
48
+
49
+ export function printReleaseRequestSummary(record: any): string {
50
+ if (!record) {
51
+ return "no release request";
52
+ }
53
+ const summary = record.payloadSummary || { count: 0, sellerIds: [] };
54
+ return [
55
+ `id: #${record.id}`,
56
+ `status: ${record.status}`,
57
+ `vendor: ${record.vendorId}`,
58
+ `sellers: ${summary.count} (${(summary.sellerIds || []).join(", ") || "—"})`,
59
+ `submittedAt: ${record.submittedAt || "—"}`,
60
+ `decidedAt: ${record.decidedAt || "—"}`,
61
+ `version: ${record.publishedVersion !== null && record.publishedVersion !== undefined ? `v${record.publishedVersion}` : "—"}`,
62
+ record.note ? `note: ${record.note}` : null,
63
+ record.errorMessage ? `error: ${record.errorMessage}` : null
64
+ ].filter(Boolean).join("\n");
65
+ }
@@ -512,6 +512,14 @@ describe("Admin CLI Config Profile Management Tests", () => {
512
512
  const state = new AdminUiState({
513
513
  configManager: mgr,
514
514
  profile: "bootstrap",
515
+ // Step 13 v1.1: 双源. fixture seller tbs-sin-06 在 fly + registry
516
+ // 都有, dataSource=both, 走老 4-endpoint merge 路径.
517
+ flyApps: async () => [{
518
+ name: "tbs-sin-06",
519
+ status: "running",
520
+ owner: "vendor-a",
521
+ raw: {}
522
+ }],
515
523
  balanceFetch: async (url, init) => {
516
524
  expect(String(url)).toBe("https://openrouter.ai/api/v1/credits");
517
525
  expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer fixture-live-key-27f9");
@@ -558,6 +566,14 @@ describe("Admin CLI Config Profile Management Tests", () => {
558
566
  const state = new AdminUiState({
559
567
  configManager: mgr,
560
568
  url: "https://bootstrap.example.test",
569
+ // Step 13 v1.1: 双源. fixture seller tbs-openrouter-ai-qecae 在 fly + registry
570
+ // 都有, dataSource=both, 走老 4-endpoint merge (用 Fly provider secret).
571
+ flyApps: async () => [{
572
+ name: "tbs-openrouter-ai-qecae",
573
+ status: "running",
574
+ owner: "vendor-a",
575
+ raw: {}
576
+ }],
561
577
  fetchJson: async (url, init) => {
562
578
  const pathName = new URL(url).pathname;
563
579
  if (pathName === "/registry/sellers") {
@@ -567,7 +583,10 @@ describe("Admin CLI Config Profile Management Tests", () => {
567
583
  id: "tbs-openrouter-ai-qecae",
568
584
  name: "tbs-openrouter-ai-qecae",
569
585
  app: "tbs-openrouter-ai-qecae",
570
- url: "https://tbs-openrouter-ai-qecae.fly.dev",
586
+ // Step 13 v1.1: 双源下, 4 个老 endpoint 也用 entry.url. 测试
587
+ // 故意用 bootstrap URL, 这样 mock fetchJson 能 catch 全部 4 个
588
+ // fetch (manifest probe 仍走 entry.url, 404 时 fallback 走 4-endpoint).
589
+ url: "https://bootstrap.example.test",
571
590
  status: "active",
572
591
  region: "sin",
573
592
  modelsCount: 2,
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Step 12 v1.1: tb-admin seller 子命令真实 CLI 测试.
3
+ *
4
+ * 原则: 不 mock. 全部跑真 `tb-admin` CLI (它内部调真 flyctl). 通过 --dry-run 模式
5
+ * 避免真改 fly 资源.
6
+ *
7
+ * 覆盖:
8
+ * - ls/status/create/deploy/remove 5 op 都有 文本路径 + --json 路径
9
+ * - --json 输出可被 JSON.parse
10
+ * - 文本路径不破坏 1.0.31 行为 (跟 flyctl 原始 stdout 一致)
11
+ * - dry-run 模式返回 commands 数组
12
+ *
13
+ * 依赖:
14
+ * - 真 flyctl 在 PATH (本机已装 v0.4.53)
15
+ * - tb-admin CLI 是 source-built 1:1 (跟 git checkout 一致), bin/tb-admin.js 可执行
16
+ *
17
+ * 失败模式:
18
+ * - 没装 flyctl: 大部分 test skip (it.skip / describe.skip)
19
+ * - 没 token: 1.0.31 行为不变, CLI 仍然跑 flyctl, 但 flyctl 鉴权失败的部分不影响 dry-run
20
+ */
21
+
22
+ import { execFileSync } from "node:child_process";
23
+ import { existsSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { ConfigManager } from "../src/config.js";
26
+ import { SellerCommandRunner, parseFlyListJson, parseFlyStatusJson } from "../src/seller.js";
27
+ import { FlyProvider, type FlyProviderRuntime } from "../src/server-cmd.js";
28
+
29
+ const TB_ADMIN_BIN = join(__dirname, "..", "bin", "tb-admin.js");
30
+ const FLYCTL = "flyctl";
31
+
32
+ function flyctlInstalled(): boolean {
33
+ try {
34
+ execFileSync(`which ${FLYCTL}`, { stdio: "ignore" });
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ function tbAdminBinInstalled(): boolean {
42
+ return existsSync(TB_ADMIN_BIN);
43
+ }
44
+
45
+ function runTbAdminReal(args: string[]): { ok: boolean; stdout: string; stderr: string } {
46
+ try {
47
+ const stdout = execFileSync("node", [TB_ADMIN_BIN, ...args], {
48
+ encoding: "utf8",
49
+ stdio: ["ignore", "pipe", "pipe"],
50
+ env: { ...process.env, NO_COLOR: "1" }
51
+ });
52
+ return { ok: true, stdout, stderr: "" };
53
+ } catch (err) {
54
+ const e = err as { stdout?: Buffer | string; stderr?: Buffer | string };
55
+ return {
56
+ ok: false,
57
+ stdout: typeof e.stdout === "string" ? e.stdout : e.stdout ? e.stdout.toString() : "",
58
+ stderr: typeof e.stderr === "string" ? e.stderr : e.stderr ? e.stderr.toString() : ""
59
+ };
60
+ }
61
+ }
62
+
63
+ describe("seller parser helpers (pure, no IO)", () => {
64
+ test("parseFlyListJson normalizes flyctl apps list --json", () => {
65
+ const jsonText = JSON.stringify([
66
+ { Name: "tbs-aaa", Status: "deployed", Owner: "personal", Version: 7, Deployed: true },
67
+ { Name: "tbs-bbb", Status: "suspended", Owner: "personal", Version: 7, Deployed: false }
68
+ ]);
69
+ const apps = parseFlyListJson(jsonText);
70
+ expect(apps).toHaveLength(2);
71
+ expect(apps[0].name).toBe("tbs-aaa");
72
+ expect(apps[0].status).toBe("deployed");
73
+ expect(apps[1].status).toBe("suspended");
74
+ expect(apps[0].raw).toMatchObject({ Name: "tbs-aaa" });
75
+ });
76
+
77
+ test("parseFlyListJson throws on invalid JSON", () => {
78
+ expect(() => parseFlyListJson("not-json")).toThrow(/invalid JSON/);
79
+ });
80
+
81
+ test("parseFlyListJson throws on non-array", () => {
82
+ expect(() => parseFlyListJson('{"foo":"bar"}')).toThrow(/did not return an array/);
83
+ });
84
+
85
+ test("parseFlyStatusJson parses single app status", () => {
86
+ const jsonText = JSON.stringify({ Name: "tbs-aaa", Status: "deployed", Deployed: true });
87
+ const status = parseFlyStatusJson(jsonText);
88
+ expect(status).toMatchObject({ Name: "tbs-aaa" });
89
+ });
90
+
91
+ test("parseFlyStatusJson throws on array input", () => {
92
+ expect(() => parseFlyStatusJson("[]")).toThrow(/did not return an object/);
93
+ });
94
+ });
95
+
96
+ describe("tb-admin seller CLI real spawn (no mock)", () => {
97
+ // 没用 beforeAll: 一些 jest sandbox 下 beforeAll 抛错会让整个 describe fail.
98
+ // 每个 test 进来时现场探测.
99
+ const itRequires = (name: string, fn: () => void | Promise<void>) => {
100
+ test(name, async () => {
101
+ const hasFlyctl = flyctlInstalled();
102
+ const hasBin = tbAdminBinInstalled();
103
+ if (!hasFlyctl || !hasBin) {
104
+ const reason = !hasFlyctl ? "flyctl not in PATH" : "tb-admin bin not built";
105
+ console.warn(`[skipped] ${name}: ${reason}`);
106
+ return;
107
+ }
108
+ await fn();
109
+ });
110
+ };
111
+
112
+ itRequires("ls text path returns flyctl human-readable table (1.0.31 behavior preserved)", () => {
113
+ const result = runTbAdminReal(["seller", "ls"]);
114
+ expect(result.ok).toBe(true);
115
+ // flyctl apps list 文本输出有 "│" 列分隔符
116
+ expect(result.stdout).toMatch(/│/);
117
+ expect(result.stdout).toMatch(/STATUS/);
118
+ });
119
+
120
+ itRequires("ls --json returns valid JSON object with ok, provider, action, count, apps[]", () => {
121
+ const result = runTbAdminReal(["seller", "ls", "--json"]);
122
+ expect(result.ok).toBe(true);
123
+ const parsed = JSON.parse(result.stdout);
124
+ expect(parsed).toMatchObject({ ok: true, provider: "fly", action: "list" });
125
+ expect(typeof parsed.count).toBe("number");
126
+ expect(Array.isArray(parsed.apps)).toBe(true);
127
+ expect(parsed.apps.length).toBe(parsed.count);
128
+ if (parsed.apps.length > 0) {
129
+ expect(parsed.apps[0]).toHaveProperty("name");
130
+ expect(parsed.apps[0]).toHaveProperty("status");
131
+ expect(parsed.apps[0]).toHaveProperty("raw");
132
+ }
133
+ });
134
+
135
+ itRequires("status --json returns valid JSON object with ok, provider, action, app, status{}", () => {
136
+ // 用 live 真实存在的 tbs-86d81e app (1.0.31 live)
137
+ const result = runTbAdminReal(["seller", "status", "tbs-86d81e", "--json"]);
138
+ expect(result.ok).toBe(true);
139
+ const parsed = JSON.parse(result.stdout);
140
+ expect(parsed).toMatchObject({
141
+ ok: true,
142
+ provider: "fly",
143
+ action: "status",
144
+ app: "tbs-86d81e"
145
+ });
146
+ expect(parsed.status).toBeDefined();
147
+ expect(typeof parsed.status).toBe("object");
148
+ });
149
+
150
+ itRequires("create --json --dry-run returns commands array, no real flyctl effect", () => {
151
+ const result = runTbAdminReal([
152
+ "seller", "create", "test-dry-run-app",
153
+ "--image", "registry.fly.io/tb-seller:1.0.31",
154
+ "--fly-config", "deploy/fly.io/fly.tb-seller.toml",
155
+ "--operator-secret", "test-secret",
156
+ "--region", "sin",
157
+ "--dry-run", "--json"
158
+ ]);
159
+ expect(result.ok).toBe(true);
160
+ const parsed = JSON.parse(result.stdout);
161
+ expect(parsed).toMatchObject({
162
+ ok: true,
163
+ provider: "fly",
164
+ action: "create",
165
+ dryRun: true
166
+ });
167
+ expect(Array.isArray(parsed.commands)).toBe(true);
168
+ expect(parsed.commands.length).toBeGreaterThan(0);
169
+ // 包含 fly deploy + fly machine update 命令
170
+ const allCommands = parsed.commands.join(" ");
171
+ expect(allCommands).toMatch(/tb-seller/);
172
+ });
173
+
174
+ itRequires("deploy --json --dry-run returns single machine update command", () => {
175
+ const result = runTbAdminReal([
176
+ "seller", "deploy", "tbs-86d81e",
177
+ "--image", "registry.fly.io/tb-seller:1.0.31",
178
+ "--dry-run", "--json"
179
+ ]);
180
+ expect(result.ok).toBe(true);
181
+ const parsed = JSON.parse(result.stdout);
182
+ expect(parsed).toMatchObject({
183
+ ok: true,
184
+ provider: "fly",
185
+ action: "deploy",
186
+ app: "tbs-86d81e",
187
+ dryRun: true
188
+ });
189
+ expect(parsed.commands[0]).toContain("machine update");
190
+ expect(parsed.commands[0]).toContain("tbs-86d81e");
191
+ });
192
+
193
+ itRequires("remove --json --dry-run returns apps destroy command", () => {
194
+ const result = runTbAdminReal([
195
+ "seller", "remove", "tbs-86d81e",
196
+ "--dry-run", "--json"
197
+ ]);
198
+ expect(result.ok).toBe(true);
199
+ const parsed = JSON.parse(result.stdout);
200
+ expect(parsed).toMatchObject({
201
+ ok: true,
202
+ provider: "fly",
203
+ action: "remove",
204
+ dryRun: true
205
+ });
206
+ expect(parsed.commands[0]).toContain("apps destroy tbs-86d81e");
207
+ });
208
+ });
209
+
210
+ describe("SellerCommandRunner integration with real ConfigManager (no mock)", () => {
211
+ // 验证 Runner 行为通过真 ConfigManager 拉真 config (有 token, 装好 flyctl 的环境)
212
+ function tryRealConfigManager(): ConfigManager | null {
213
+ const home = process.env.HOME || "";
214
+ const adminToml = join(home, ".config", "tokenbuddy", "admin.toml");
215
+ if (!existsSync(adminToml)) {
216
+ return null;
217
+ }
218
+ return new ConfigManager(adminToml);
219
+ }
220
+
221
+ test("ls(false) returns flyctl stdout string via real FlyProvider (no mock)", () => {
222
+ const mgr = tryRealConfigManager();
223
+ if (!mgr) {
224
+ // 环境无 admin profile, 用空 ConfigManager 走 fallback
225
+ const tmp = new ConfigManager("/tmp/tb-admin-no-config-" + Date.now() + ".toml");
226
+ const runner = new SellerCommandRunner(tmp);
227
+ // 没有 provider config -> 走 FlyProvider -> flyctl not installed (test env) -> throw
228
+ try {
229
+ runner.ls(false);
230
+ throw new Error("expected flyctl-not-installed error");
231
+ } catch (err) {
232
+ expect((err as Error).message).toMatch(/flyctl|not installed|seller_providers/);
233
+ }
234
+ return;
235
+ }
236
+ const runner = new SellerCommandRunner(mgr);
237
+ const out = runner.ls(false);
238
+ expect(typeof out).toBe("string");
239
+ expect(out).toMatch(/│/);
240
+ });
241
+ });
242
+
243
+ describe("FlyProvider dry-run paths (no mock, real flyctl required)", () => {
244
+ // 1.0.31 行为 1:1 验证: FlyProvider 已有方法的 dry-run 模式不调 flyctl 真子命令,
245
+ // 只输出 plan. 这是 FlyProvider 的核心契约.
246
+
247
+ function makeRealProvider(): FlyProvider | null {
248
+ const home = process.env.HOME || "";
249
+ const adminToml = join(home, ".config", "tokenbuddy", "admin.toml");
250
+ if (!existsSync(adminToml)) {
251
+ return null;
252
+ }
253
+ const mgr = new ConfigManager(adminToml);
254
+ const cfg = mgr.getSellerProvider("fly");
255
+ if (!cfg) {
256
+ return null;
257
+ }
258
+ // 1.0.31 FlyProvider 接口 1:1 行为验证, runtime 走真值
259
+ const runtime: Partial<FlyProviderRuntime> = {};
260
+ return new FlyProvider(cfg, runtime);
261
+ }
262
+
263
+ test("createSeller dry-run returns plan string without calling flyctl", () => {
264
+ const provider = makeRealProvider();
265
+ if (!provider) {
266
+ // 没 admin profile -> skip
267
+ return;
268
+ }
269
+ const plan = provider.createSeller({
270
+ name: "alpha",
271
+ region: "sin",
272
+ image: "registry.fly.io/tb-seller:1.0.31",
273
+ operatorSecret: "secret",
274
+ flyConfig: "deploy/fly.io/fly.tb-seller.toml",
275
+ volumeName: "tb_seller_data",
276
+ volumeSizeGb: 1,
277
+ dryRun: true
278
+ });
279
+ expect(plan).toMatch(/DRY-RUN/);
280
+ expect(plan).toContain("tb-seller-alpha");
281
+ expect(plan).toContain("registry.fly.io/tb-seller:1.0.31");
282
+ });
283
+
284
+ test("deploySeller dry-run returns plan string without calling flyctl", () => {
285
+ const provider = makeRealProvider();
286
+ if (!provider) {
287
+ return;
288
+ }
289
+ const plan = provider.deploySeller({
290
+ app: "tbs-86d81e",
291
+ image: "registry.fly.io/tb-seller:1.0.31",
292
+ dryRun: true
293
+ });
294
+ expect(plan).toMatch(/DRY-RUN/);
295
+ expect(plan).toContain("tbs-86d81e");
296
+ });
297
+
298
+ test("removeSeller dry-run returns plan string without calling flyctl", () => {
299
+ const provider = makeRealProvider();
300
+ if (!provider) {
301
+ return;
302
+ }
303
+ const plan = provider.removeSeller("tbs-86d81e", true);
304
+ expect(plan).toMatch(/DRY-RUN/);
305
+ expect(plan).toContain("tbs-86d81e");
306
+ });
307
+ });
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Step 13 v1.1: admin web seller list 双源 (fly + registry) 数据流测试.
3
+ *
4
+ * 原则: 不 mock flyctl. 测的是 AdminUiState 内部数据流, 用注入的
5
+ * fetchJson (fake HTTP server) + flyApps (closure) 替代真 IO.
6
+ *
7
+ * 覆盖 (跟 docs/processes/seller-fleet-data-sources.md 一致):
8
+ * 1. fly + registry both → 行 dataSource="both", 绿点 = /manifest 200
9
+ * 2. fly only → 行 dataSource="fly", 灰点 (无 instance url 拉 manifest 失败),
10
+ * 行带 "未发布" 提示
11
+ * 3. registry only → 行 dataSource="registry", **整行标红 (registryAlert=true)**,
12
+ * 行带 "未部署" 提示 + 立即下线按钮 (我们这里只验字段, UI 验由 ui-static 验)
13
+ * 4. fly + registry both, 但 /manifest 失败 → 灰/红点, 仍 dataSource="both"
14
+ *
15
+ * 隔离外部依赖:
16
+ * - node:http fake server 模拟 registry (/registry/sellers)
17
+ * - node:http fake server 模拟 seller instance (/manifest)
18
+ * - flyApps 通过 AdminUiStateOptions.flyApps 注入 closure
19
+ *
20
+ * 失败模式:
21
+ * - 如果 4 个 case 任何一个 dataSource 错 / nodeStatus 错 / alert 漏, 立即 fail
22
+ * - 永不调真 flyctl
23
+ */
24
+
25
+ import { createServer, type Server } from "node:http";
26
+ import { AddressInfo } from "node:net";
27
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
28
+ import { tmpdir } from "node:os";
29
+ import { join } from "node:path";
30
+ import { ConfigManager } from "../src/config.js";
31
+ import { AdminUiState } from "../src/ui-state.js";
32
+ import type { SellerAppJson } from "../src/seller.js";
33
+
34
+ interface FakeManifestResponse {
35
+ status: number;
36
+ body?: unknown;
37
+ }
38
+
39
+ interface FakeServerHandle {
40
+ url: string;
41
+ setManifest: (sellerId: string, resp: FakeManifestResponse) => void;
42
+ setManifestPath: (path: string, resp: FakeManifestResponse) => void;
43
+ setRegistry: (resp: { sellers: any[]; version: number }) => void;
44
+ close: () => Promise<void>;
45
+ }
46
+
47
+ function startFakeRegistry(): Promise<FakeServerHandle> {
48
+ let registryDoc: { sellers: any[]; version: number } = { sellers: [], version: 0 };
49
+ const manifests = new Map<string, FakeManifestResponse>();
50
+
51
+ return new Promise((resolve) => {
52
+ const server = createServer((req, res) => {
53
+ const url = new URL(req.url || "/", "http://localhost");
54
+ if (url.pathname === "/registry/sellers") {
55
+ res.writeHead(200, { "Content-Type": "application/json" });
56
+ res.end(JSON.stringify(registryDoc));
57
+ return;
58
+ }
59
+ // 1) 按 path 查 (setManifestPath 注册的)
60
+ if (manifests.has(url.pathname)) {
61
+ const resp = manifests.get(url.pathname)!;
62
+ res.writeHead(resp.status, { "Content-Type": "application/json" });
63
+ res.end(JSON.stringify(resp.body || {}));
64
+ return;
65
+ }
66
+ // 2) 按 /manifest/{id} 模式查 (setManifest 注册的)
67
+ const match = url.pathname.match(/^\/manifest\/([^/]+)$/);
68
+ if (match) {
69
+ const id = decodeURIComponent(match[1]);
70
+ const resp = manifests.get(id) || { status: 404, body: { error: "not found" } };
71
+ res.writeHead(resp.status, { "Content-Type": "application/json" });
72
+ res.end(JSON.stringify(resp.body || {}));
73
+ return;
74
+ }
75
+ res.writeHead(404);
76
+ res.end("not found");
77
+ });
78
+ server.listen(0, "127.0.0.1", () => {
79
+ const port = (server.address() as AddressInfo).port;
80
+ const handle: FakeServerHandle = {
81
+ url: `http://127.0.0.1:${port}`,
82
+ setManifest: (id, resp) => {
83
+ manifests.set(id, resp);
84
+ },
85
+ setManifestPath: (path, resp) => {
86
+ manifests.set(path, resp);
87
+ },
88
+ setRegistry: (doc) => {
89
+ registryDoc = doc;
90
+ },
91
+ close: () => new Promise<void>((r) => server.close(() => r()))
92
+ };
93
+ resolve(handle);
94
+ });
95
+ });
96
+ }
97
+
98
+ let tmpDir: string;
99
+ let tmpConfigPath: string;
100
+
101
+ function makeState(
102
+ registryUrl: string,
103
+ flyApps: Omit<SellerAppJson, "raw">[]
104
+ ): AdminUiState {
105
+ if (!tmpDir) {
106
+ tmpDir = mkdtempSync(join(tmpdir(), "ui-fleet-test-"));
107
+ tmpConfigPath = join(tmpDir, "admin.toml");
108
+ }
109
+ writeFileSync(
110
+ tmpConfigPath,
111
+ `[profiles.default]
112
+ url = "${registryUrl}"
113
+ token = "fake-vendor-token"
114
+ `,
115
+ "utf8"
116
+ );
117
+ const configManager = new ConfigManager(tmpConfigPath);
118
+ return new AdminUiState({
119
+ configManager,
120
+ profile: "default",
121
+ url: registryUrl,
122
+ token: "fake-vendor-token",
123
+ flyApps: async () => flyApps.map((a) => ({ ...a, raw: {} })),
124
+ fetchJson: (async (target: string, init?: RequestInit) => {
125
+ const res = await fetch(target, init);
126
+ return await res.json();
127
+ }) as any
128
+ });
129
+ }
130
+
131
+ describe("AdminUiState 双源 seller list (v1.1 spec)", () => {
132
+ let fake: FakeServerHandle;
133
+
134
+ beforeEach(async () => {
135
+ fake = await startFakeRegistry();
136
+ });
137
+
138
+ afterEach(async () => {
139
+ await fake.close();
140
+ });
141
+
142
+ afterAll(() => {
143
+ if (tmpDir) {
144
+ rmSync(tmpDir, { recursive: true, force: true });
145
+ }
146
+ });
147
+
148
+ test("case 1: fly + registry both → dataSource=both, 绿点 = /manifest 200", async () => {
149
+ fake.setRegistry({
150
+ version: 7,
151
+ sellers: [
152
+ {
153
+ id: "alpha",
154
+ name: "Alpha Seller",
155
+ app: "tb-seller-alpha",
156
+ // entry.url 是 instance 自己的 url; probeManifest 会拼 + "/manifest"
157
+ url: `${fake.url}/alpha-base`,
158
+ status: "active",
159
+ supportedProtocols: ["chat_completions"],
160
+ paymentMethods: ["clawtip"],
161
+ models: ["gpt-5.4"]
162
+ }
163
+ ]
164
+ });
165
+ // entry.url = ".../alpha-base", probe target = ".../alpha-base/manifest"
166
+ fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
167
+ const state = makeState(fake.url, [
168
+ { name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
169
+ ]);
170
+
171
+ const rows = await state.sellers();
172
+ expect(rows).toHaveLength(1);
173
+ expect(rows[0].dataSource).toBe("both");
174
+ expect(rows[0].registryAlert).toBeFalsy();
175
+ // 绿点: /manifest 200 → nodeStatus=active (manifest ok 映射 active)
176
+ expect(rows[0].nodeStatus).toBe("active");
177
+ });
178
+
179
+ test("case 2: fly only → dataSource=fly, 灰点, registryAlert=undefined", async () => {
180
+ fake.setRegistry({ version: 7, sellers: [] });
181
+ const state = makeState(fake.url, [
182
+ { name: "tb-seller-flyonly", status: "running", owner: "vendor-a" }
183
+ ]);
184
+
185
+ const rows = await state.sellers();
186
+ expect(rows).toHaveLength(1);
187
+ expect(rows[0].dataSource).toBe("fly");
188
+ expect(rows[0].nodeStatus).toBe("unknown");
189
+ expect(rows[0].registryAlert).toBeFalsy();
190
+ expect(rows[0].publishHint).toContain("未发布");
191
+ });
192
+
193
+ test("case 3: registry only → dataSource=registry, 整行标红, 立即下线提示", async () => {
194
+ fake.setRegistry({
195
+ version: 7,
196
+ sellers: [
197
+ {
198
+ id: "ghost",
199
+ name: "Ghost Seller",
200
+ app: "tb-seller-ghost",
201
+ url: "https://tbs-ghost.fly.dev",
202
+ status: "active",
203
+ supportedProtocols: ["chat_completions"],
204
+ paymentMethods: ["clawtip"],
205
+ models: ["gpt-5.4"]
206
+ }
207
+ ]
208
+ });
209
+ const state = makeState(fake.url, [
210
+ // 注意: fly list 完全没有 tb-seller-ghost
211
+ { name: "tb-seller-other", status: "running", owner: "vendor-a" }
212
+ ]);
213
+
214
+ const rows = await state.sellers();
215
+ expect(rows).toHaveLength(2); // ghost + other
216
+ const ghost = rows.find((r) => r.id === "ghost");
217
+ expect(ghost).toBeDefined();
218
+ expect(ghost!.dataSource).toBe("registry");
219
+ expect(ghost!.registryAlert).toBe(true);
220
+ expect(ghost!.alertReason).toContain("registry 收录了但 fly app 失踪");
221
+ expect(ghost!.removeHint).toContain("立即下线 (registry-only)");
222
+
223
+ const other = rows.find((r) => r.app === "tb-seller-other");
224
+ expect(other).toBeDefined();
225
+ expect(other!.dataSource).toBe("fly");
226
+ expect(other!.registryAlert).toBeFalsy();
227
+ });
228
+
229
+ test("case 4: both, /manifest 失败 → 灰/红点, dataSource 仍 = both", async () => {
230
+ fake.setRegistry({
231
+ version: 7,
232
+ sellers: [
233
+ {
234
+ id: "down",
235
+ name: "Down Seller",
236
+ app: "tb-seller-down",
237
+ url: `${fake.url}/down-base`,
238
+ status: "active",
239
+ supportedProtocols: ["chat_completions"],
240
+ paymentMethods: ["clawtip"],
241
+ models: ["gpt-5.4"]
242
+ }
243
+ ]
244
+ });
245
+ fake.setManifestPath("/down-base/manifest", { status: 502, body: { error: "upstream down" } });
246
+ const state = makeState(fake.url, [
247
+ { name: "tb-seller-down", status: "running", owner: "vendor-a" }
248
+ ]);
249
+
250
+ const rows = await state.sellers();
251
+ expect(rows).toHaveLength(1);
252
+ expect(rows[0].dataSource).toBe("both");
253
+ expect(rows[0].registryAlert).toBeFalsy();
254
+ // /manifest 失败 → nodeStatus=unknown (不是 active)
255
+ expect(rows[0].nodeStatus).toBe("unknown");
256
+ });
257
+ });