@tokenbuddy/tb-admin 1.0.31 → 1.0.33

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 (53) 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.d.ts.map +1 -1
  21. package/dist/src/ui-server.js +15 -2
  22. package/dist/src/ui-server.js.map +1 -1
  23. package/dist/src/ui-state.d.ts +77 -2
  24. package/dist/src/ui-state.d.ts.map +1 -1
  25. package/dist/src/ui-state.js +242 -14
  26. package/dist/src/ui-state.js.map +1 -1
  27. package/dist/src/ui-static.d.ts.map +1 -1
  28. package/dist/src/ui-static.js +98 -20
  29. package/dist/src/ui-static.js.map +1 -1
  30. package/dist/src/vendor-client.d.ts +23 -0
  31. package/dist/src/vendor-client.d.ts.map +1 -0
  32. package/dist/src/vendor-client.js +2 -0
  33. package/dist/src/vendor-client.js.map +1 -0
  34. package/dist/src/vendor-commands.d.ts +35 -0
  35. package/dist/src/vendor-commands.d.ts.map +1 -0
  36. package/dist/src/vendor-commands.js +33 -0
  37. package/dist/src/vendor-commands.js.map +1 -0
  38. package/package.json +1 -1
  39. package/src/cli.ts +305 -31
  40. package/src/client.ts +118 -2
  41. package/src/provider.ts +150 -0
  42. package/src/seller.ts +362 -0
  43. package/src/ui-actions.ts +89 -11
  44. package/src/ui-server.ts +15 -1
  45. package/src/ui-state.ts +293 -15
  46. package/src/ui-static.ts +98 -20
  47. package/src/vendor-client.ts +23 -0
  48. package/src/vendor-commands.ts +65 -0
  49. package/tests/admin.test.ts +81 -3
  50. package/tests/seller.test.ts +337 -0
  51. package/tests/ui-state-fleet.test.ts +257 -0
  52. package/tests/ui-static-row.test.ts +202 -0
  53. package/tests/vendor-cli.test.ts +241 -0
@@ -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
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Step 13 v1.1: admin web UI seller row 渲染测试.
3
+ *
4
+ * 原则: 不引入 jsdom / linkedom. 直接 evaluate ui-static.ts bundle 的
5
+ * inline script 抽出 sellerRow 函数 (regex 匹配 function 声明) + 喂 mock
6
+ * window.__tbFmt, 然后 string match 验证 4 类行 (dataSource=both / fly /
7
+ * registry / 老 1.0.31 缺字段) 渲染出正确的 CSS class + 文案 + 按钮.
8
+ *
9
+ * 覆盖 (跟 docs/processes/seller-fleet-data-sources.md 一致):
10
+ * 1. dataSource="both" → "app-row" (无红/灰 class), 绿点 active, "Both" chip
11
+ * 2. dataSource="fly" → "app-row app-row-fly-only" (灰), "未发布" chip
12
+ * + publish-hint-btn 文案
13
+ * 3. dataSource="registry" → "app-row app-row-registry-only" (整行红边)
14
+ * + "严重事故" + alert-reason + remove-hint-btn
15
+ * 4. dataSource 老 1.0.31 缺失 → 兜底 "both" (默认行, 不报红)
16
+ *
17
+ * 验证 grep 锚点 (跟 spec 一致):
18
+ * - "立即下线 (registry-only)" 必须在 remove-hint-btn 出现
19
+ * - "registry 收录了但 fly app 失踪" 必须在 alert-reason 出现
20
+ * - "未发布 — 可申请发布" 必须在 publish-hint-btn 出现
21
+ */
22
+
23
+ import { adminUiHtml } from "../src/ui-static.js";
24
+
25
+ interface MockFmt {
26
+ UNKNOWN_VALUE: string;
27
+ formatDuration: (v: unknown) => string;
28
+ formatSpeed: (v: unknown) => string;
29
+ formatBalanceAmount: () => string;
30
+ formatSellerCapacity: () => string;
31
+ formatDiscountRatio: (v: unknown) => string;
32
+ formatSellerStatus: (s: unknown) => string;
33
+ sellerStatusTone: (s: unknown) => string;
34
+ normalizeStatusLabel: (s: unknown) => string;
35
+ formatTimeCompact: (s: unknown) => string;
36
+ formatCount: (n: unknown) => string;
37
+ }
38
+
39
+ const MOCK_FMT: MockFmt = {
40
+ UNKNOWN_VALUE: "—",
41
+ formatDuration: () => "123ms",
42
+ formatSpeed: () => "12.3 tok/s",
43
+ formatBalanceAmount: () => "0.00",
44
+ formatSellerCapacity: () => "3/8",
45
+ formatDiscountRatio: () => "1.0x",
46
+ formatSellerStatus: (s) => String(s || "unknown"),
47
+ sellerStatusTone: (s) => {
48
+ const k = String(s || "unknown").toLowerCase();
49
+ if (k === "active" || k === "healthy") return "green";
50
+ if (k === "draining" || k === "degraded") return "amber";
51
+ if (k === "offline" || k === "unhealthy") return "red";
52
+ if (k === "busy_capacity") return "blue";
53
+ return "gray";
54
+ },
55
+ normalizeStatusLabel: (s) => String(s || "—"),
56
+ formatTimeCompact: () => "12:34",
57
+ formatCount: (n) => String(n ?? 0)
58
+ };
59
+
60
+ /**
61
+ * Step 13 v1.1: 不引 jsdom, 用 `new Function` 跑抽出 sellerRow 函数体.
62
+ * ui-static.ts 整个文件是 `<script>...</script>` 模板字符串, 我们用
63
+ * 简单 regex 提取 inline script 内容, 加上 mock window, 跑 sellerRow 拿 HTML.
64
+ *
65
+ * 限制: 不模拟 DOM 树. 验证方式是直接看 sellerRow() 返回的 HTML 字符串
66
+ * 是否含预期的 class + 文案 + 按钮 (跟 spec grep 锚点一一对应).
67
+ */
68
+ function renderRow(row: Record<string, unknown>): string {
69
+ const html = adminUiHtml();
70
+ // 抽 <script>...</script>
71
+ const scriptMatch = html.match(/<script>([\s\S]*?)<\/script>/);
72
+ if (!scriptMatch) {
73
+ throw new Error("adminUiHtml() did not include a <script> tag");
74
+ }
75
+ const scriptBody = scriptMatch[1];
76
+ // mock window + 把 sellerRow 暴露
77
+ const code = `
78
+ var noopArr = () => [];
79
+ var fakeEl = { classList: { toggle: () => undefined, add: () => undefined, remove: () => undefined }, innerHTML: "", textContent: "" };
80
+ var window = { __tbFmt: ${JSON.stringify(MOCK_FMT)}, addEventListener: () => undefined };
81
+ var document = {
82
+ createElement: () => fakeEl,
83
+ addEventListener: () => undefined,
84
+ querySelectorAll: noopArr,
85
+ querySelector: () => fakeEl,
86
+ getElementById: () => fakeEl
87
+ };
88
+ var setTimeout = () => 0;
89
+ var setInterval = () => 0;
90
+ var clearTimeout = () => undefined;
91
+ var clearInterval = () => undefined;
92
+ ${scriptBody}
93
+ return sellerRow(${JSON.stringify(row)});
94
+ `;
95
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
96
+ const fn = new Function(code);
97
+ return fn();
98
+ }
99
+
100
+ describe("admin web seller row 渲染 (Step 13 v1.1 双源 spec)", () => {
101
+ test("dataSource=both → 正常行 (无红/灰 class), 绿点, Both chip", () => {
102
+ const out = renderRow({
103
+ id: "tbs-alpha",
104
+ name: "tbs-alpha",
105
+ app: "tbs-alpha",
106
+ url: "https://tbs-alpha.fly.dev",
107
+ registryStatus: "active",
108
+ nodeStatus: "active",
109
+ upstreamDomain: "openrouter.ai",
110
+ upstreamStatus: "healthy",
111
+ modelsCount: 1,
112
+ dataSource: "both",
113
+ flyApp: { name: "tbs-alpha", status: "running" }
114
+ });
115
+ // 行 class 不带 registry-only / fly-only
116
+ expect(out).toContain('class="app-row"');
117
+ expect(out).not.toContain("app-row-registry-only");
118
+ expect(out).not.toContain("app-row-fly-only");
119
+ // 绿点 active → tone-green
120
+ expect(out).toMatch(/app-dot tone-green/);
121
+ // Both chip
122
+ expect(out).toMatch(/datasource-chip both/);
123
+ expect(out).toContain("Both");
124
+ // 无 alert-reason / remove-hint-btn / publish-hint-btn
125
+ expect(out).not.toContain("alert-reason");
126
+ expect(out).not.toContain("remove-hint-btn");
127
+ expect(out).not.toContain("publish-hint-btn");
128
+ });
129
+
130
+ test("dataSource=fly → 灰底 + 未发布 chip + publish-hint-btn", () => {
131
+ const out = renderRow({
132
+ id: "tbs-flyonly",
133
+ name: "tbs-flyonly",
134
+ app: "tbs-flyonly",
135
+ url: "https://tbs-flyonly.fly.dev",
136
+ registryStatus: "unknown",
137
+ nodeStatus: "unknown",
138
+ upstreamDomain: "tbs-flyonly.fly.dev",
139
+ upstreamStatus: "unknown",
140
+ modelsCount: 0,
141
+ dataSource: "fly",
142
+ publishHint: "未发布 — 可申请发布 (走 vendor-bootstrap stage)",
143
+ flyApp: { name: "tbs-flyonly", status: "running" }
144
+ });
145
+ expect(out).toContain("app-row-fly-only");
146
+ expect(out).not.toContain("app-row-registry-only");
147
+ expect(out).toMatch(/datasource-chip fly/);
148
+ expect(out).toContain("未发布");
149
+ // publish-hint-btn 出现 + 文案含 spec 关键词
150
+ expect(out).toContain("publish-hint-btn");
151
+ expect(out).toContain("未发布 — 可申请发布");
152
+ });
153
+
154
+ test("dataSource=registry → 整行红边 + 严重事故 + alert-reason + 立即下线 (registry-only) 按钮", () => {
155
+ const out = renderRow({
156
+ id: "tbs-ghost",
157
+ name: "tbs-ghost",
158
+ app: "tbs-ghost",
159
+ url: "https://tbs-ghost.fly.dev",
160
+ registryStatus: "active",
161
+ nodeStatus: "unknown",
162
+ upstreamDomain: "tbs-ghost.fly.dev",
163
+ upstreamStatus: "unknown",
164
+ modelsCount: 1,
165
+ dataSource: "registry",
166
+ registryAlert: true,
167
+ alertReason: "registry 收录了但 fly app 失踪 — 严重事故, 立即下线",
168
+ removeHint: "立即下线 (registry-only)"
169
+ });
170
+ expect(out).toContain("app-row-registry-only");
171
+ // 严重事故 文案
172
+ expect(out).toContain("严重事故");
173
+ // alert-reason
174
+ expect(out).toContain("alert-reason");
175
+ expect(out).toContain("registry 收录了但 fly app 失踪");
176
+ // 立即下线 (registry-only) 按钮
177
+ expect(out).toContain("remove-hint-btn");
178
+ expect(out).toContain("立即下线 (registry-only)");
179
+ expect(out).toMatch(/data-action="remove"/);
180
+ expect(out).toMatch(/data-seller-id="tbs-ghost"/);
181
+ });
182
+
183
+ test("dataSource 缺失 (老 1.0.31 行) → 兜底 'both', 不报错不标红", () => {
184
+ const out = renderRow({
185
+ id: "legacy",
186
+ name: "legacy",
187
+ app: "legacy",
188
+ url: "https://legacy.fly.dev",
189
+ registryStatus: "active",
190
+ nodeStatus: "active",
191
+ upstreamDomain: "legacy.fly.dev",
192
+ upstreamStatus: "healthy",
193
+ modelsCount: 1
194
+ // 故意没 dataSource 字段, 模拟 1.0.31 老 row
195
+ });
196
+ expect(out).toContain('class="app-row"');
197
+ expect(out).not.toContain("app-row-registry-only");
198
+ expect(out).not.toContain("app-row-fly-only");
199
+ // 兜底 chip = both
200
+ expect(out).toMatch(/datasource-chip both/);
201
+ });
202
+ });