@tokenbuddy/tb-admin 1.0.31 → 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.
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +280 -19
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.d.ts +82 -2
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +93 -0
- package/dist/src/client.js.map +1 -1
- package/dist/src/provider.d.ts +120 -0
- package/dist/src/provider.d.ts.map +1 -0
- package/dist/src/provider.js +73 -0
- package/dist/src/provider.js.map +1 -0
- package/dist/src/seller.d.ts +104 -0
- package/dist/src/seller.d.ts.map +1 -0
- package/dist/src/seller.js +283 -0
- package/dist/src/seller.js.map +1 -0
- package/dist/src/ui-actions.d.ts +25 -0
- package/dist/src/ui-actions.d.ts.map +1 -1
- package/dist/src/ui-actions.js +81 -11
- package/dist/src/ui-actions.js.map +1 -1
- package/dist/src/ui-server.js +9 -0
- package/dist/src/ui-server.js.map +1 -1
- package/dist/src/ui-state.d.ts +77 -2
- package/dist/src/ui-state.d.ts.map +1 -1
- package/dist/src/ui-state.js +242 -14
- package/dist/src/ui-state.js.map +1 -1
- package/dist/src/ui-static.d.ts.map +1 -1
- package/dist/src/ui-static.js +95 -17
- package/dist/src/ui-static.js.map +1 -1
- package/dist/src/vendor-client.d.ts +23 -0
- package/dist/src/vendor-client.d.ts.map +1 -0
- package/dist/src/vendor-client.js +2 -0
- package/dist/src/vendor-client.js.map +1 -0
- package/dist/src/vendor-commands.d.ts +35 -0
- package/dist/src/vendor-commands.d.ts.map +1 -0
- package/dist/src/vendor-commands.js +33 -0
- package/dist/src/vendor-commands.js.map +1 -0
- package/package.json +1 -1
- package/src/cli.ts +305 -31
- package/src/client.ts +119 -2
- package/src/provider.ts +150 -0
- package/src/seller.ts +362 -0
- package/src/ui-actions.ts +89 -11
- package/src/ui-server.ts +9 -0
- package/src/ui-state.ts +293 -15
- package/src/ui-static.ts +95 -17
- package/src/vendor-client.ts +23 -0
- package/src/vendor-commands.ts +65 -0
- package/tests/admin.test.ts +20 -1
- package/tests/seller.test.ts +307 -0
- package/tests/ui-state-fleet.test.ts +257 -0
- package/tests/ui-static-row.test.ts +202 -0
- 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
|
+
}
|
package/tests/admin.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|