@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.
- 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.d.ts.map +1 -1
- package/dist/src/ui-server.js +15 -2
- 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 +98 -20
- 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 +118 -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 +15 -1
- package/src/ui-state.ts +293 -15
- package/src/ui-static.ts +98 -20
- package/src/vendor-client.ts +23 -0
- package/src/vendor-commands.ts +65 -0
- package/tests/admin.test.ts +81 -3
- package/tests/seller.test.ts +337 -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 +241 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type alias for the seller entry shape vendor commands work with.
|
|
3
|
+
* Mirrors the server-side `SellerRegistryEntry` but stays decoupled
|
|
4
|
+
* from `wallet-bootstrap`'s internal types so this package does not
|
|
5
|
+
* become a build-time dependency of the registry service.
|
|
6
|
+
*/
|
|
7
|
+
export interface SellerRegistryEntry {
|
|
8
|
+
id: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
profile?: string;
|
|
11
|
+
app?: string;
|
|
12
|
+
url: string;
|
|
13
|
+
status?: string;
|
|
14
|
+
region?: string;
|
|
15
|
+
modelsCount?: number;
|
|
16
|
+
sampleModels?: string[];
|
|
17
|
+
models?: string[];
|
|
18
|
+
supportedProtocols: string[];
|
|
19
|
+
paymentMethods: string[];
|
|
20
|
+
recommendedFor?: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { RegistryVendorClient, RegistryAdminClient } from "./client.js";
|
|
@@ -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
|
@@ -38,6 +38,7 @@ import * as vm from "vm";
|
|
|
38
38
|
|
|
39
39
|
const TEMP_CONF_PATH = path.resolve(__dirname, "../../data-test/admin-config.json");
|
|
40
40
|
const PACKAGE_JSON = path.resolve(__dirname, "../package.json");
|
|
41
|
+
const ADMIN_UI_STATE_TEST_TIMEOUT_MS = 15_000;
|
|
41
42
|
|
|
42
43
|
describe("Admin CLI Config Profile Management Tests", () => {
|
|
43
44
|
beforeEach(() => {
|
|
@@ -90,6 +91,17 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
90
91
|
expect(() => new vm.Script(scripts[0])).not.toThrow();
|
|
91
92
|
});
|
|
92
93
|
|
|
94
|
+
test("admin UI release requests tab targets the releases page and loader", () => {
|
|
95
|
+
const html = adminUiHtml();
|
|
96
|
+
|
|
97
|
+
expect(html).toContain('data-page="releases"');
|
|
98
|
+
expect(html).toContain('id="page-releases"');
|
|
99
|
+
expect(html).toContain('id="releasesGrid"');
|
|
100
|
+
expect(html).toContain('btn.dataset.page === "releases"');
|
|
101
|
+
expect(html).not.toContain('id="page-bootstrap"');
|
|
102
|
+
expect(html).not.toContain('getElementById("bootstrapGrid")');
|
|
103
|
+
});
|
|
104
|
+
|
|
93
105
|
test("Switch default profiles", () => {
|
|
94
106
|
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
95
107
|
mgr.setProfile("prod", { url: "http://127.0.0.1:8000", token: "secret-op" });
|
|
@@ -406,6 +418,53 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
406
418
|
})).rejects.toThrow("loopback");
|
|
407
419
|
});
|
|
408
420
|
|
|
421
|
+
test("tb-admin ui release requests endpoint proxies the active vendor profile", async () => {
|
|
422
|
+
const registry = http.createServer((req, res) => {
|
|
423
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
424
|
+
if (req.method === "GET" && url.pathname === "/platform/release-requests") {
|
|
425
|
+
expect(req.headers.authorization).toBe("Bearer vendor-token-value");
|
|
426
|
+
expect(url.searchParams.get("limit")).toBe("20");
|
|
427
|
+
sendJson(res, {
|
|
428
|
+
releaseRequests: [{
|
|
429
|
+
id: 42,
|
|
430
|
+
status: "pending",
|
|
431
|
+
submittedAt: "2026-06-11T00:00:00.000Z",
|
|
432
|
+
sellerCount: 1,
|
|
433
|
+
note: "fixture release"
|
|
434
|
+
}]
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
res.statusCode = 404;
|
|
439
|
+
sendJson(res, { error: "not found" });
|
|
440
|
+
});
|
|
441
|
+
await new Promise<void>((resolve) => registry.listen(0, "127.0.0.1", () => resolve()));
|
|
442
|
+
const address = registry.address();
|
|
443
|
+
if (!address || typeof address !== "object") {
|
|
444
|
+
throw new Error("registry fixture did not bind a TCP port");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
448
|
+
mgr.setProfile("bootstrap", { url: `http://127.0.0.1:${address.port}`, token: "vendor-token-value" });
|
|
449
|
+
const started = await startAdminUiServer({
|
|
450
|
+
host: "127.0.0.1",
|
|
451
|
+
port: 0,
|
|
452
|
+
openBrowser: false,
|
|
453
|
+
configManager: mgr,
|
|
454
|
+
profile: "bootstrap"
|
|
455
|
+
});
|
|
456
|
+
try {
|
|
457
|
+
const response = await fetch(`${started.url}api/vendor/release-requests`);
|
|
458
|
+
expect(response.status).toBe(200);
|
|
459
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
460
|
+
releaseRequests: [{ id: 42, status: "pending", note: "fixture release" }]
|
|
461
|
+
});
|
|
462
|
+
} finally {
|
|
463
|
+
await new Promise<void>((resolve) => started.server.close(() => resolve()));
|
|
464
|
+
await new Promise<void>((resolve) => registry.close(() => resolve()));
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
409
468
|
test("tb-admin ui create seller returns a progress job", async () => {
|
|
410
469
|
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
411
470
|
mgr.setSellerProvider("fly", { operator_secret: "operator-token-value" });
|
|
@@ -512,6 +571,14 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
512
571
|
const state = new AdminUiState({
|
|
513
572
|
configManager: mgr,
|
|
514
573
|
profile: "bootstrap",
|
|
574
|
+
// Step 13 v1.1: 双源. fixture seller tbs-sin-06 在 fly + registry
|
|
575
|
+
// 都有, dataSource=both, 走老 4-endpoint merge 路径.
|
|
576
|
+
flyApps: async () => [{
|
|
577
|
+
name: "tbs-sin-06",
|
|
578
|
+
status: "running",
|
|
579
|
+
owner: "vendor-a",
|
|
580
|
+
raw: {}
|
|
581
|
+
}],
|
|
515
582
|
balanceFetch: async (url, init) => {
|
|
516
583
|
expect(String(url)).toBe("https://openrouter.ai/api/v1/credits");
|
|
517
584
|
expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer fixture-live-key-27f9");
|
|
@@ -550,7 +617,7 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
550
617
|
} finally {
|
|
551
618
|
await fixture.close();
|
|
552
619
|
}
|
|
553
|
-
});
|
|
620
|
+
}, ADMIN_UI_STATE_TEST_TIMEOUT_MS);
|
|
554
621
|
|
|
555
622
|
test("AdminUiState uses Fly provider operator secret for registry sellers without local profiles", async () => {
|
|
556
623
|
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
@@ -558,6 +625,14 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
558
625
|
const state = new AdminUiState({
|
|
559
626
|
configManager: mgr,
|
|
560
627
|
url: "https://bootstrap.example.test",
|
|
628
|
+
// Step 13 v1.1: 双源. fixture seller tbs-openrouter-ai-qecae 在 fly + registry
|
|
629
|
+
// 都有, dataSource=both, 走老 4-endpoint merge (用 Fly provider secret).
|
|
630
|
+
flyApps: async () => [{
|
|
631
|
+
name: "tbs-openrouter-ai-qecae",
|
|
632
|
+
status: "running",
|
|
633
|
+
owner: "vendor-a",
|
|
634
|
+
raw: {}
|
|
635
|
+
}],
|
|
561
636
|
fetchJson: async (url, init) => {
|
|
562
637
|
const pathName = new URL(url).pathname;
|
|
563
638
|
if (pathName === "/registry/sellers") {
|
|
@@ -567,7 +642,10 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
567
642
|
id: "tbs-openrouter-ai-qecae",
|
|
568
643
|
name: "tbs-openrouter-ai-qecae",
|
|
569
644
|
app: "tbs-openrouter-ai-qecae",
|
|
570
|
-
|
|
645
|
+
// Step 13 v1.1: 双源下, 4 个老 endpoint 也用 entry.url. 测试
|
|
646
|
+
// 故意用 bootstrap URL, 这样 mock fetchJson 能 catch 全部 4 个
|
|
647
|
+
// fetch (manifest probe 仍走 entry.url, 404 时 fallback 走 4-endpoint).
|
|
648
|
+
url: "https://bootstrap.example.test",
|
|
571
649
|
status: "active",
|
|
572
650
|
region: "sin",
|
|
573
651
|
modelsCount: 2,
|
|
@@ -623,7 +701,7 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
623
701
|
expect(detail.configuration.upstreamUrl).toBe("https://openrouter.ai/api");
|
|
624
702
|
expect(detail.configuration.upstreamApiKeyMasked).toBe("**** **** **** abcd");
|
|
625
703
|
expect(detail.models.map((model) => model.upstreamModel)).toEqual(["openai/gpt-5.4", "anthropic/claude-opus-4.7"]);
|
|
626
|
-
});
|
|
704
|
+
}, ADMIN_UI_STATE_TEST_TIMEOUT_MS);
|
|
627
705
|
|
|
628
706
|
test("UiActions updates new registry seller config without a local profile", async () => {
|
|
629
707
|
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
@@ -0,0 +1,337 @@
|
|
|
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, mkdtempSync } from "node:fs";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { ConfigManager } from "../src/config.js";
|
|
27
|
+
import { SellerCommandRunner, parseFlyListJson, parseFlyStatusJson } from "../src/seller.js";
|
|
28
|
+
import { FlyProvider, type FlyProviderRuntime } from "../src/server-cmd.js";
|
|
29
|
+
|
|
30
|
+
const TB_ADMIN_BIN = join(__dirname, "..", "bin", "tb-admin.js");
|
|
31
|
+
const FLYCTL = "flyctl";
|
|
32
|
+
const RUN_LIVE_FLY_TESTS = process.env.TOKENBUDDY_ADMIN_LIVE_FLY_TESTS === "1";
|
|
33
|
+
|
|
34
|
+
function flyctlInstalled(): boolean {
|
|
35
|
+
try {
|
|
36
|
+
execFileSync("which", [FLYCTL], { stdio: "ignore" });
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
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
|
+
|
|
54
|
+
function tbAdminBinInstalled(): boolean {
|
|
55
|
+
return existsSync(TB_ADMIN_BIN);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function runTbAdminReal(args: string[]): { ok: boolean; stdout: string; stderr: string } {
|
|
59
|
+
try {
|
|
60
|
+
const stdout = execFileSync("node", [TB_ADMIN_BIN, ...args], {
|
|
61
|
+
encoding: "utf8",
|
|
62
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
63
|
+
env: { ...process.env, NO_COLOR: "1" }
|
|
64
|
+
});
|
|
65
|
+
return { ok: true, stdout, stderr: "" };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const e = err as { stdout?: Buffer | string; stderr?: Buffer | string };
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
stdout: typeof e.stdout === "string" ? e.stdout : e.stdout ? e.stdout.toString() : "",
|
|
71
|
+
stderr: typeof e.stderr === "string" ? e.stderr : e.stderr ? e.stderr.toString() : ""
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("seller parser helpers (pure, no IO)", () => {
|
|
77
|
+
test("parseFlyListJson normalizes flyctl apps list --json", () => {
|
|
78
|
+
const jsonText = JSON.stringify([
|
|
79
|
+
{ Name: "tbs-aaa", Status: "deployed", Owner: "personal", Version: 7, Deployed: true },
|
|
80
|
+
{ Name: "tbs-bbb", Status: "suspended", Owner: "personal", Version: 7, Deployed: false }
|
|
81
|
+
]);
|
|
82
|
+
const apps = parseFlyListJson(jsonText);
|
|
83
|
+
expect(apps).toHaveLength(2);
|
|
84
|
+
expect(apps[0].name).toBe("tbs-aaa");
|
|
85
|
+
expect(apps[0].status).toBe("deployed");
|
|
86
|
+
expect(apps[1].status).toBe("suspended");
|
|
87
|
+
expect(apps[0].raw).toMatchObject({ Name: "tbs-aaa" });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("parseFlyListJson throws on invalid JSON", () => {
|
|
91
|
+
expect(() => parseFlyListJson("not-json")).toThrow(/invalid JSON/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("parseFlyListJson throws on non-array", () => {
|
|
95
|
+
expect(() => parseFlyListJson('{"foo":"bar"}')).toThrow(/did not return an array/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("parseFlyStatusJson parses single app status", () => {
|
|
99
|
+
const jsonText = JSON.stringify({ Name: "tbs-aaa", Status: "deployed", Deployed: true });
|
|
100
|
+
const status = parseFlyStatusJson(jsonText);
|
|
101
|
+
expect(status).toMatchObject({ Name: "tbs-aaa" });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("parseFlyStatusJson throws on array input", () => {
|
|
105
|
+
expect(() => parseFlyStatusJson("[]")).toThrow(/did not return an object/);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("tb-admin seller CLI real spawn (no mock)", () => {
|
|
110
|
+
// 没用 beforeAll: 一些 jest sandbox 下 beforeAll 抛错会让整个 describe fail.
|
|
111
|
+
// 每个 test 进来时现场探测.
|
|
112
|
+
const itRequires = (name: string, fn: () => void | Promise<void>) => {
|
|
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
|
+
}
|
|
118
|
+
const hasFlyctl = flyctlInstalled();
|
|
119
|
+
const hasBin = tbAdminBinInstalled();
|
|
120
|
+
if (!hasFlyctl || !hasBin) {
|
|
121
|
+
const reason = !hasFlyctl ? "flyctl not in PATH" : "tb-admin bin not built";
|
|
122
|
+
console.warn(`[skipped] ${name}: ${reason}`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await fn();
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
itRequires("ls text path returns flyctl human-readable table (1.0.31 behavior preserved)", () => {
|
|
130
|
+
const result = runTbAdminReal(["seller", "ls"]);
|
|
131
|
+
expect(result.ok).toBe(true);
|
|
132
|
+
// flyctl apps list 文本输出有 "│" 列分隔符
|
|
133
|
+
expect(result.stdout).toMatch(/│/);
|
|
134
|
+
expect(result.stdout).toMatch(/STATUS/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
itRequires("ls --json returns valid JSON object with ok, provider, action, count, apps[]", () => {
|
|
138
|
+
const result = runTbAdminReal(["seller", "ls", "--json"]);
|
|
139
|
+
expect(result.ok).toBe(true);
|
|
140
|
+
const parsed = JSON.parse(result.stdout);
|
|
141
|
+
expect(parsed).toMatchObject({ ok: true, provider: "fly", action: "list" });
|
|
142
|
+
expect(typeof parsed.count).toBe("number");
|
|
143
|
+
expect(Array.isArray(parsed.apps)).toBe(true);
|
|
144
|
+
expect(parsed.apps.length).toBe(parsed.count);
|
|
145
|
+
if (parsed.apps.length > 0) {
|
|
146
|
+
expect(parsed.apps[0]).toHaveProperty("name");
|
|
147
|
+
expect(parsed.apps[0]).toHaveProperty("status");
|
|
148
|
+
expect(parsed.apps[0]).toHaveProperty("raw");
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
itRequires("status --json returns valid JSON object with ok, provider, action, app, status{}", () => {
|
|
153
|
+
// 用 live 真实存在的 tbs-86d81e app (1.0.31 live)
|
|
154
|
+
const result = runTbAdminReal(["seller", "status", "tbs-86d81e", "--json"]);
|
|
155
|
+
expect(result.ok).toBe(true);
|
|
156
|
+
const parsed = JSON.parse(result.stdout);
|
|
157
|
+
expect(parsed).toMatchObject({
|
|
158
|
+
ok: true,
|
|
159
|
+
provider: "fly",
|
|
160
|
+
action: "status",
|
|
161
|
+
app: "tbs-86d81e"
|
|
162
|
+
});
|
|
163
|
+
expect(parsed.status).toBeDefined();
|
|
164
|
+
expect(typeof parsed.status).toBe("object");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
itRequires("create --json --dry-run returns commands array, no real flyctl effect", () => {
|
|
168
|
+
const result = runTbAdminReal([
|
|
169
|
+
"seller", "create", "test-dry-run-app",
|
|
170
|
+
"--image", "registry.fly.io/tb-seller:1.0.31",
|
|
171
|
+
"--fly-config", "deploy/fly.io/fly.tb-seller.toml",
|
|
172
|
+
"--operator-secret", "test-secret",
|
|
173
|
+
"--region", "sin",
|
|
174
|
+
"--dry-run", "--json"
|
|
175
|
+
]);
|
|
176
|
+
expect(result.ok).toBe(true);
|
|
177
|
+
const parsed = JSON.parse(result.stdout);
|
|
178
|
+
expect(parsed).toMatchObject({
|
|
179
|
+
ok: true,
|
|
180
|
+
provider: "fly",
|
|
181
|
+
action: "create",
|
|
182
|
+
dryRun: true
|
|
183
|
+
});
|
|
184
|
+
expect(Array.isArray(parsed.commands)).toBe(true);
|
|
185
|
+
expect(parsed.commands.length).toBeGreaterThan(0);
|
|
186
|
+
// 包含 fly deploy + fly machine update 命令
|
|
187
|
+
const allCommands = parsed.commands.join(" ");
|
|
188
|
+
expect(allCommands).toMatch(/tb-seller/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
itRequires("deploy --json --dry-run returns single machine update command", () => {
|
|
192
|
+
const result = runTbAdminReal([
|
|
193
|
+
"seller", "deploy", "tbs-86d81e",
|
|
194
|
+
"--image", "registry.fly.io/tb-seller:1.0.31",
|
|
195
|
+
"--dry-run", "--json"
|
|
196
|
+
]);
|
|
197
|
+
expect(result.ok).toBe(true);
|
|
198
|
+
const parsed = JSON.parse(result.stdout);
|
|
199
|
+
expect(parsed).toMatchObject({
|
|
200
|
+
ok: true,
|
|
201
|
+
provider: "fly",
|
|
202
|
+
action: "deploy",
|
|
203
|
+
app: "tbs-86d81e",
|
|
204
|
+
dryRun: true
|
|
205
|
+
});
|
|
206
|
+
expect(parsed.commands[0]).toContain("machine update");
|
|
207
|
+
expect(parsed.commands[0]).toContain("tbs-86d81e");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
itRequires("remove --json --dry-run returns apps destroy command", () => {
|
|
211
|
+
const result = runTbAdminReal([
|
|
212
|
+
"seller", "remove", "tbs-86d81e",
|
|
213
|
+
"--dry-run", "--json"
|
|
214
|
+
]);
|
|
215
|
+
expect(result.ok).toBe(true);
|
|
216
|
+
const parsed = JSON.parse(result.stdout);
|
|
217
|
+
expect(parsed).toMatchObject({
|
|
218
|
+
ok: true,
|
|
219
|
+
provider: "fly",
|
|
220
|
+
action: "remove",
|
|
221
|
+
dryRun: true
|
|
222
|
+
});
|
|
223
|
+
expect(parsed.commands[0]).toContain("apps destroy tbs-86d81e");
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("SellerCommandRunner integration with real ConfigManager (no mock)", () => {
|
|
228
|
+
// 验证 Runner 行为通过真 ConfigManager 拉真 config (有 token, 装好 flyctl 的环境)
|
|
229
|
+
function tryRealConfigManager(): ConfigManager | null {
|
|
230
|
+
const home = process.env.HOME || "";
|
|
231
|
+
const adminToml = join(home, ".config", "tokenbuddy", "admin.toml");
|
|
232
|
+
if (!existsSync(adminToml)) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
return new ConfigManager(adminToml);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
test("ls(false) returns flyctl stdout string via real FlyProvider (no mock)", () => {
|
|
239
|
+
if (!RUN_LIVE_FLY_TESTS) {
|
|
240
|
+
const runner = new SellerCommandRunner(makeMissingFlyctlConfigManager());
|
|
241
|
+
expect(() => runner.ls(false)).toThrow(/flyctl|not installed|seller_providers/);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const mgr = tryRealConfigManager();
|
|
245
|
+
if (!mgr) {
|
|
246
|
+
// 环境无 admin profile, 用空 ConfigManager 走 fallback
|
|
247
|
+
const tmp = makeEmptyConfigManager();
|
|
248
|
+
const runner = new SellerCommandRunner(tmp);
|
|
249
|
+
// 没有 provider config -> 走 FlyProvider -> flyctl not installed (test env) -> throw
|
|
250
|
+
try {
|
|
251
|
+
runner.ls(false);
|
|
252
|
+
throw new Error("expected flyctl-not-installed error");
|
|
253
|
+
} catch (err) {
|
|
254
|
+
expect((err as Error).message).toMatch(/flyctl|not installed|seller_providers/);
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const runner = new SellerCommandRunner(mgr);
|
|
259
|
+
const out = runner.ls(false);
|
|
260
|
+
expect(typeof out).toBe("string");
|
|
261
|
+
expect(out).toMatch(/│/);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("FlyProvider dry-run paths (no mock, real flyctl required)", () => {
|
|
266
|
+
// 1.0.31 行为 1:1 验证: FlyProvider 已有方法的 dry-run 模式不调 flyctl 真子命令,
|
|
267
|
+
// 只输出 plan. 这是 FlyProvider 的核心契约.
|
|
268
|
+
|
|
269
|
+
function makeRealProvider(): FlyProvider | null {
|
|
270
|
+
if (!RUN_LIVE_FLY_TESTS) {
|
|
271
|
+
return new FlyProvider({
|
|
272
|
+
default_region: "sin",
|
|
273
|
+
operator_secret: "test-secret",
|
|
274
|
+
volume_name: "tb_seller_data",
|
|
275
|
+
volume_size_gb: 1
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
const home = process.env.HOME || "";
|
|
279
|
+
const adminToml = join(home, ".config", "tokenbuddy", "admin.toml");
|
|
280
|
+
if (!existsSync(adminToml)) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
const mgr = new ConfigManager(adminToml);
|
|
284
|
+
const cfg = mgr.getSellerProvider("fly");
|
|
285
|
+
if (!cfg) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
// 1.0.31 FlyProvider 接口 1:1 行为验证, runtime 走真值
|
|
289
|
+
const runtime: Partial<FlyProviderRuntime> = {};
|
|
290
|
+
return new FlyProvider(cfg, runtime);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
test("createSeller dry-run returns plan string without calling flyctl", () => {
|
|
294
|
+
const provider = makeRealProvider();
|
|
295
|
+
if (!provider) {
|
|
296
|
+
// 没 admin profile -> skip
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const plan = provider.createSeller({
|
|
300
|
+
name: "alpha",
|
|
301
|
+
region: "sin",
|
|
302
|
+
image: "registry.fly.io/tb-seller:1.0.31",
|
|
303
|
+
operatorSecret: "secret",
|
|
304
|
+
flyConfig: "deploy/fly.io/fly.tb-seller.toml",
|
|
305
|
+
volumeName: "tb_seller_data",
|
|
306
|
+
volumeSizeGb: 1,
|
|
307
|
+
dryRun: true
|
|
308
|
+
});
|
|
309
|
+
expect(plan).toMatch(/DRY-RUN/);
|
|
310
|
+
expect(plan).toContain("tb-seller-alpha");
|
|
311
|
+
expect(plan).toContain("registry.fly.io/tb-seller:1.0.31");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("deploySeller dry-run returns plan string without calling flyctl", () => {
|
|
315
|
+
const provider = makeRealProvider();
|
|
316
|
+
if (!provider) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const plan = provider.deploySeller({
|
|
320
|
+
app: "tbs-86d81e",
|
|
321
|
+
image: "registry.fly.io/tb-seller:1.0.31",
|
|
322
|
+
dryRun: true
|
|
323
|
+
});
|
|
324
|
+
expect(plan).toMatch(/DRY-RUN/);
|
|
325
|
+
expect(plan).toContain("tbs-86d81e");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("removeSeller dry-run returns plan string without calling flyctl", () => {
|
|
329
|
+
const provider = makeRealProvider();
|
|
330
|
+
if (!provider) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const plan = provider.removeSeller("tbs-86d81e", true);
|
|
334
|
+
expect(plan).toMatch(/DRY-RUN/);
|
|
335
|
+
expect(plan).toContain("tbs-86d81e");
|
|
336
|
+
});
|
|
337
|
+
});
|