@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,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
+ }
@@ -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
- url: "https://tbs-openrouter-ai-qecae.fly.dev",
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
+ });