@tokenbuddy/tb-admin 1.0.30 → 1.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +280 -19
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/client.d.ts +82 -2
  5. package/dist/src/client.d.ts.map +1 -1
  6. package/dist/src/client.js +93 -0
  7. package/dist/src/client.js.map +1 -1
  8. package/dist/src/provider.d.ts +120 -0
  9. package/dist/src/provider.d.ts.map +1 -0
  10. package/dist/src/provider.js +73 -0
  11. package/dist/src/provider.js.map +1 -0
  12. package/dist/src/seller.d.ts +104 -0
  13. package/dist/src/seller.d.ts.map +1 -0
  14. package/dist/src/seller.js +283 -0
  15. package/dist/src/seller.js.map +1 -0
  16. package/dist/src/ui-actions.d.ts +25 -0
  17. package/dist/src/ui-actions.d.ts.map +1 -1
  18. package/dist/src/ui-actions.js +81 -11
  19. package/dist/src/ui-actions.js.map +1 -1
  20. package/dist/src/ui-server.js +9 -0
  21. package/dist/src/ui-server.js.map +1 -1
  22. package/dist/src/ui-state.d.ts +77 -2
  23. package/dist/src/ui-state.d.ts.map +1 -1
  24. package/dist/src/ui-state.js +242 -14
  25. package/dist/src/ui-state.js.map +1 -1
  26. package/dist/src/ui-static.d.ts.map +1 -1
  27. package/dist/src/ui-static.js +95 -17
  28. package/dist/src/ui-static.js.map +1 -1
  29. package/dist/src/vendor-client.d.ts +23 -0
  30. package/dist/src/vendor-client.d.ts.map +1 -0
  31. package/dist/src/vendor-client.js +2 -0
  32. package/dist/src/vendor-client.js.map +1 -0
  33. package/dist/src/vendor-commands.d.ts +35 -0
  34. package/dist/src/vendor-commands.d.ts.map +1 -0
  35. package/dist/src/vendor-commands.js +33 -0
  36. package/dist/src/vendor-commands.js.map +1 -0
  37. package/package.json +1 -1
  38. package/src/cli.ts +305 -31
  39. package/src/client.ts +119 -2
  40. package/src/provider.ts +150 -0
  41. package/src/seller.ts +362 -0
  42. package/src/ui-actions.ts +89 -11
  43. package/src/ui-server.ts +9 -0
  44. package/src/ui-state.ts +293 -15
  45. package/src/ui-static.ts +95 -17
  46. package/src/vendor-client.ts +23 -0
  47. package/src/vendor-commands.ts +65 -0
  48. package/tests/admin.test.ts +20 -1
  49. package/tests/seller.test.ts +307 -0
  50. package/tests/ui-state-fleet.test.ts +257 -0
  51. package/tests/ui-static-row.test.ts +202 -0
  52. package/tests/vendor-cli.test.ts +197 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Step 12 (provider-agnostic seller management):
3
+ * 抽象 seller provider 接口, 让 tb-admin CLI + UI 不再 hardcode
4
+ * Fly.io 调用。
5
+ *
6
+ * 设计原则 (跟用户 ZCG 在 2026-06-11 凌晨讨论):
7
+ * - 一个 `SellerProvider` 实现对应一种底层平台 (Fly.io / mock / ...).
8
+ * - 5 个核心操作: listApps / statusApp / createApp / deployApp / removeApp.
9
+ * - 所有方法都接受 dryRun, 都在 provider config 缺失必填字段时抛错.
10
+ * - 所有方法都支持 `json: true` 输出, 方便 UI / 测试 / 自动化调用.
11
+ * - Provider 通过 `ProviderRegistry` 注册, 默认 'fly' 实现是
12
+ * `FlyProvider` (从 server-cmd.ts 拆出), 'mock' 是测试实现.
13
+ * - 调用方 (CLI / UI / 其他) 都通过 `ProviderRegistry.get(name)` 取.
14
+ * - `tb-admin seller <cmd>` CLI 子命令全部加 `--provider <name>`
15
+ * (默认 'fly') + `--json` flag.
16
+ *
17
+ * 注意: Fly 字段 (`flyctl_path` / `default_region` / ...) 仍放在
18
+ * `SellerProviderConfig` 里, 不引入新的 provider 专用 config 字段
19
+ * (避免拆得过细). 未来加 K8s / 自建集群 provider 时, 把 `flyctl_path`
20
+ * 等字段移到 `FlyConfig` 子接口, 让 `SellerProviderConfig` 持有
21
+ * 通用字段 (default_region / default_image / volume_* 跨 provider 通用).
22
+ */
23
+
24
+ import type { SellerProviderConfig } from "./config.js";
25
+
26
+ /** Dry-run / json 通用选项, 所有方法都接受 */
27
+ export interface ProviderCallOptions {
28
+ dryRun?: boolean;
29
+ /** 静默: 不打 [Fly.io] / [Mock] 之类的人类可读日志, 只输出 json */
30
+ silent?: boolean;
31
+ }
32
+
33
+ /** Provider 必须能列出所有它知道的应用, 跨 provider 字段对齐 */
34
+ export interface SellerAppEntry {
35
+ /** Provider 内的应用 id (Fly 跟 K8s 各自定义) */
36
+ name: string;
37
+ /** 状态 (active / suspended / deployed / pending / unknown). 各 provider 各自定义枚举. */
38
+ status: string;
39
+ /** Provider 内部 id, 跨 provider 不必有意义. UI 用它跳转详情. */
40
+ providerId: string;
41
+ /** Region / zone / namespace. */
42
+ region?: string;
43
+ /** 上次部署时间 ISO 字符串. */
44
+ lastDeployAt?: string;
45
+ /** provider 特有的元数据 (machines count / image / 等) */
46
+ metadata?: Record<string, unknown>;
47
+ }
48
+
49
+ /** Provider 创建入参. 字段尽量 provider-agnostic; 私有字段放 `providerOptions` */
50
+ export interface SellerCreateInput {
51
+ name: string;
52
+ region?: string;
53
+ image?: string;
54
+ operatorSecret: string;
55
+ initialConfigPath?: string;
56
+ volumeName?: string;
57
+ volumeSizeGb?: number;
58
+ volumeId?: string;
59
+ volumeSnapshotRetentionDays?: number;
60
+ dryRun?: boolean;
61
+ /** provider 私有扩展 (FlyConfig / K8sConfig / ...) */
62
+ providerOptions?: Record<string, unknown>;
63
+ }
64
+
65
+ /** Provider deploy 入参 */
66
+ export interface SellerDeployInput {
67
+ app: string;
68
+ image?: string;
69
+ dryRun?: boolean;
70
+ providerOptions?: Record<string, unknown>;
71
+ }
72
+
73
+ /** Provider 通用 5 操作接口 */
74
+ export interface SellerProvider {
75
+ /** provider 名 ('fly' / 'mock' / 'k8s' 等), 用于 `tb-admin --provider <name>` 选择 */
76
+ readonly name: string;
77
+
78
+ /** 检测 provider runtime 是否装好 (flyctl / kubectl / etc). CLI 启动时调用一次. */
79
+ isAvailable(): boolean;
80
+
81
+ /** List all apps this provider manages. */
82
+ listApps(options?: ProviderCallOptions): Promise<SellerAppEntry[]>;
83
+
84
+ /** Get one app's status (machine state / region / last deploy). */
85
+ statusApp(name: string, options?: ProviderCallOptions): Promise<SellerAppEntry>;
86
+
87
+ /** Create + initial deploy. `dryRun` 模式只输出计划. */
88
+ createApp(input: SellerCreateInput): Promise<{ ok: true; app: string; dryRun: boolean }>;
89
+
90
+ /** Update image on existing app. */
91
+ deployApp(input: SellerDeployInput): Promise<{ ok: true; app: string; machines: string[]; dryRun: boolean }>;
92
+
93
+ /** Destroy app. */
94
+ removeApp(name: string, options?: ProviderCallOptions): Promise<{ ok: true; app: string; dryRun: boolean }>;
95
+ }
96
+
97
+ /**
98
+ * Provider registry: CLI / UI / 测试都通过 `ProviderRegistry.get(name)`
99
+ * 拿 provider. 构造时塞一份 SellerProviderConfig, 跟现有
100
+ * `configManager.getSellerProvider(name)` 同源.
101
+ */
102
+ export class ProviderRegistry {
103
+ private providers = new Map<string, SellerProvider>();
104
+
105
+ constructor(private configManager: { getSellerProvider(name?: string): SellerProviderConfig | undefined }) {}
106
+
107
+ /** 注册一个 provider. 测试场景用. */
108
+ register(provider: SellerProvider): void {
109
+ this.providers.set(provider.name, provider);
110
+ }
111
+
112
+ /** 取 provider. 默认 'fly'. 不存在抛错 (CLI 应该 fail fast). */
113
+ get(name: string = "fly"): SellerProvider {
114
+ const registered = this.providers.get(name);
115
+ if (registered) {
116
+ return registered;
117
+ }
118
+ // 还没注册 -> 现造一个 (FlyProvider / MockProvider 各自 lazy 构造)
119
+ const config = this.configManager.getSellerProvider(name);
120
+ if (!config) {
121
+ throw new Error(
122
+ `provider '${name}' is not configured. ` +
123
+ `Run \`tb-admin config set <profile>\` first, or add a [seller_providers.${name}] section.`
124
+ );
125
+ }
126
+ const instance = createDefaultProvider(name, config);
127
+ this.providers.set(name, instance);
128
+ return instance;
129
+ }
130
+
131
+ /** 列出所有已注册 provider 名. */
132
+ list(): string[] {
133
+ return Array.from(this.providers.keys());
134
+ }
135
+ }
136
+
137
+ /**
138
+ * 默认 provider 工厂. 'fly' -> FlyProvider.
139
+ * 任何未知 name 抛错. 通过 import 避免循环依赖, 所以放在文件底部.
140
+ */
141
+ export function createDefaultProvider(name: string, config: SellerProviderConfig): SellerProvider {
142
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
143
+ const { FlyProvider } = require("./server-cmd.js") as typeof import("./server-cmd.js");
144
+ if (name === "fly") {
145
+ // Step 14 之前 FlyProvider 暂不 implements SellerProvider (保留 1.0.31 行为 1:1).
146
+ // 这里通过 unknown 桥接; 真用时调用方应通过 SellerCommandRunner 而不是直接用 ProviderRegistry.
147
+ return new FlyProvider(config) as unknown as SellerProvider;
148
+ }
149
+ throw new Error(`unknown provider name: '${name}' (built-in: fly)`);
150
+ }
package/src/seller.ts ADDED
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Step 12 v1.1 (增量): tb-admin seller 子命令 CLI 门面层.
3
+ *
4
+ * 1.0.31 现状:
5
+ * - `tb-admin seller ls|create|deploy|remove` 4 个子命令
6
+ * - 每个命令内部直接 `console.log(FlyProvider.<method>() 的 string 返回值)`
7
+ * - 1.0.31 文本输出跟 flyctl 原始 stdout 一致 (人类可读表 / 状态文案)
8
+ *
9
+ * v1.1 增量:
10
+ * 1. 新增 `tb-admin seller status <app>` 第 5 子命令
11
+ * 2. 5 个子命令全部加 `--json` flag
12
+ * 3. `--json` 输出统一包装:
13
+ * - ls/status: 直接拿 flyctl `--json` 解析后的 array / object
14
+ * - create/deploy/remove: `{ ok, provider, action, app, dryRun, ... }`
15
+ * 4. 默认路径 1:1 保留 1.0.31 行为 (FlyProvider 不变, server-cmd.ts 不动)
16
+ * 5. UI 之后通过 spawn `tb-admin seller <cmd> --json` 取结构化数据
17
+ *
18
+ * 不改: FlyProvider 类 / server-cmd.ts / admin web / provider.ts.
19
+ * 不写 UI (Step 13).
20
+ */
21
+
22
+ import { execSync, spawnSync } from "node:child_process";
23
+ import { FlyProvider, type SellerCreateOptions } from "./server-cmd.js";
24
+ import type { ConfigManager, SellerProviderConfig } from "./config.js";
25
+
26
+ /** 单个 seller app 的 JSON 形态 (跟 flyctl apps list --json 对齐, 但只保留 UI 关心字段) */
27
+ export interface SellerAppJson {
28
+ name: string;
29
+ status: string;
30
+ owner?: string;
31
+ region?: string;
32
+ version?: number;
33
+ latestDeployAt?: string;
34
+ /** raw flyctl 字段, 保留供未来扩展 */
35
+ raw: Record<string, unknown>;
36
+ }
37
+
38
+ /** ls --json 输出 */
39
+ export interface SellerListResult {
40
+ ok: true;
41
+ provider: "fly";
42
+ action: "list";
43
+ count: number;
44
+ apps: SellerAppJson[];
45
+ }
46
+
47
+ /** status --json 输出 */
48
+ export interface SellerStatusResult {
49
+ ok: true;
50
+ provider: "fly";
51
+ action: "status";
52
+ app: string;
53
+ status: Record<string, unknown>;
54
+ }
55
+
56
+ /** create / deploy / remove --json 输出 */
57
+ export interface SellerActionResult<T extends "create" | "deploy" | "remove"> {
58
+ ok: true;
59
+ provider: "fly";
60
+ action: T;
61
+ app: string;
62
+ dryRun: boolean;
63
+ /** dry-run 模式下, 列出实际会跑的 flyctl 命令 */
64
+ commands?: string[];
65
+ /** 实际执行后, flyctl 输出摘录 (limited to stdout summary) */
66
+ summary?: string;
67
+ /** create/deploy 额外信息 */
68
+ machines?: string[];
69
+ }
70
+
71
+ export type SellerCommandResult =
72
+ | SellerListResult
73
+ | SellerStatusResult
74
+ | SellerActionResult<"create">
75
+ | SellerActionResult<"deploy">
76
+ | SellerActionResult<"remove">;
77
+
78
+ export class FlyCliMissingError extends Error {
79
+ constructor(public readonly flyctl: string) {
80
+ super(`\`${flyctl}\` is not installed on your system PATH.`);
81
+ this.name = "FlyCliMissingError";
82
+ }
83
+ }
84
+
85
+ export class FlyCommandError extends Error {
86
+ constructor(
87
+ public readonly command: string,
88
+ public readonly exitCode: number,
89
+ public readonly stderr: string
90
+ ) {
91
+ super(`flyctl command failed (${exitCode}): ${command}\n${stderr.trim()}`);
92
+ this.name = "FlyCommandError";
93
+ }
94
+ }
95
+
96
+ /**
97
+ * 解析 flyctl apps list 文本输出 (人类可读表) -> 数组.
98
+ * 默认路径不变; --json 路径用 `parseFlyListJson` 走结构化路径.
99
+ */
100
+ function parseFlyListText(text: string): SellerAppJson[] {
101
+ const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
102
+ // 第一行 = 表头, 跳过; 后续行 = app 行
103
+ return lines.slice(1).map((line) => {
104
+ const cols = line.split("│").map((c) => c.trim());
105
+ return {
106
+ name: cols[0] || "",
107
+ status: cols[2] || "unknown",
108
+ owner: cols[1] || undefined,
109
+ latestDeployAt: cols[3] || undefined,
110
+ raw: { textLine: line }
111
+ };
112
+ });
113
+ }
114
+
115
+ function parseFlyListJson(jsonText: string): SellerAppJson[] {
116
+ let parsed: unknown;
117
+ try {
118
+ parsed = JSON.parse(jsonText || "[]");
119
+ } catch (err) {
120
+ throw new Error(`flyctl apps list --json returned invalid JSON: ${(err as Error).message}`);
121
+ }
122
+ if (!Array.isArray(parsed)) {
123
+ throw new Error(`flyctl apps list --json did not return an array`);
124
+ }
125
+ return parsed.map((item) => {
126
+ if (!item || typeof item !== "object") {
127
+ return { name: String(item), status: "unknown", raw: { value: item } };
128
+ }
129
+ const obj = item as Record<string, unknown>;
130
+ const name = typeof obj.Name === "string" ? obj.Name : typeof obj.name === "string" ? obj.name : "";
131
+ const status = typeof obj.Status === "string" ? obj.Status : typeof obj.status === "string" ? obj.status : "unknown";
132
+ return {
133
+ name,
134
+ status,
135
+ owner: typeof obj.Owner === "string" ? obj.Owner : undefined,
136
+ region: typeof obj.Region === "string" ? obj.Region : undefined,
137
+ version: typeof obj.Version === "number" ? obj.Version : undefined,
138
+ latestDeployAt: typeof obj.Deployed === "boolean" && obj.Deployed ? new Date().toISOString() : undefined,
139
+ raw: obj
140
+ };
141
+ });
142
+ }
143
+
144
+ function parseFlyStatusJson(jsonText: string): Record<string, unknown> {
145
+ let parsed: unknown;
146
+ try {
147
+ parsed = JSON.parse(jsonText || "{}");
148
+ } catch (err) {
149
+ throw new Error(`flyctl status --json returned invalid JSON: ${(err as Error).message}`);
150
+ }
151
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
152
+ throw new Error(`flyctl status --json did not return an object`);
153
+ }
154
+ return parsed as Record<string, unknown>;
155
+ }
156
+
157
+ /**
158
+ * Low-level flyctl exec helper. Throws FlyCommandError on non-zero exit.
159
+ * Use this for --json path; default 1.0.31 path still goes through FlyProvider
160
+ * (which uses `stdio: "inherit"` for human consumption).
161
+ */
162
+ function runFlyctlJson(
163
+ flyctl: string,
164
+ args: string[],
165
+ env: NodeJS.ProcessEnv
166
+ ): unknown {
167
+ const result = spawnSync(flyctl, args, { encoding: "utf8", env });
168
+ if (result.error) {
169
+ throw result.error;
170
+ }
171
+ if (result.status !== 0) {
172
+ throw new FlyCommandError(
173
+ `${flyctl} ${args.join(" ")}`,
174
+ result.status ?? -1,
175
+ result.stderr || result.stdout || ""
176
+ );
177
+ }
178
+ return result.stdout;
179
+ }
180
+
181
+ /**
182
+ * 顶层 seller 命令入口. 5 个 op 都走这里.
183
+ * - `json=false` (默认): 走 FlyProvider 现有方法, 返回人类可读 string, 调用方 console.log
184
+ * - `json=true`: 走结构化 flyctl --json + 包装, 返回 SellerCommandResult 对象
185
+ */
186
+ export class SellerCommandRunner {
187
+ constructor(private configManager: ConfigManager) {}
188
+
189
+ private getProviderConfig(): SellerProviderConfig | undefined {
190
+ return this.configManager.getSellerProvider("fly");
191
+ }
192
+
193
+ private getProvider(): FlyProvider {
194
+ return new FlyProvider(this.getProviderConfig());
195
+ }
196
+
197
+ private getFlyctl(): string {
198
+ return this.getProviderConfig()?.flyctl_path || "flyctl";
199
+ }
200
+
201
+ // -- ls --
202
+
203
+ public ls(json: boolean): SellerListResult | string {
204
+ const provider = this.getProvider();
205
+ if (!json) {
206
+ return provider.listApps();
207
+ }
208
+ const flyctl = this.getFlyctl();
209
+ if (!providerCheck(flyctl)) {
210
+ throw new FlyCliMissingError(flyctl);
211
+ }
212
+ const env = flyEnv(this.getProviderConfig());
213
+ const stdout = runFlyctlJson(flyctl, ["apps", "list", "--json"], env) as string;
214
+ const apps = parseFlyListJson(stdout);
215
+ return { ok: true, provider: "fly", action: "list", count: apps.length, apps };
216
+ }
217
+
218
+ // -- status --
219
+
220
+ public status(appName: string, json: boolean): SellerStatusResult | string {
221
+ const provider = this.getProvider();
222
+ if (!json) {
223
+ return provider.statusApp(appName);
224
+ }
225
+ const flyctl = this.getFlyctl();
226
+ if (!providerCheck(flyctl)) {
227
+ throw new FlyCliMissingError(flyctl);
228
+ }
229
+ const resolvedName = appName.includes("-") ? appName : `tb-seller-${appName}`;
230
+ const env = flyEnv(this.getProviderConfig());
231
+ const stdout = runFlyctlJson(flyctl, ["status", "--app", resolvedName, "--json"], env) as string;
232
+ const status = parseFlyStatusJson(stdout);
233
+ return { ok: true, provider: "fly", action: "status", app: resolvedName, status };
234
+ }
235
+
236
+ // -- create --
237
+
238
+ public create(options: SellerCreateOptions, json: boolean): SellerActionResult<"create"> | string {
239
+ const provider = this.getProvider();
240
+ if (!json) {
241
+ return provider.createSeller(options);
242
+ }
243
+ // --json 路径: dry-run 模式直接列命令, 不实际调 flyctl
244
+ if (options.dryRun) {
245
+ return {
246
+ ok: true,
247
+ provider: "fly",
248
+ action: "create",
249
+ app: options.app || `tb-seller-${options.name}`,
250
+ dryRun: true,
251
+ commands: buildCreateCommands(options)
252
+ };
253
+ }
254
+ // 非 dry-run: 调 FlyProvider (它已经会跑 flyctl), 包装 stdout
255
+ const summary = provider.createSeller(options);
256
+ return {
257
+ ok: true,
258
+ provider: "fly",
259
+ action: "create",
260
+ app: options.app || `tb-seller-${options.name}`,
261
+ dryRun: false,
262
+ summary: String(summary)
263
+ };
264
+ }
265
+
266
+ // -- deploy --
267
+
268
+ public deploy(
269
+ args: { app: string; image?: string; dryRun?: boolean },
270
+ json: boolean
271
+ ): SellerActionResult<"deploy"> | string {
272
+ const provider = this.getProvider();
273
+ if (!json) {
274
+ return provider.deploySeller(args);
275
+ }
276
+ if (args.dryRun) {
277
+ return {
278
+ ok: true,
279
+ provider: "fly",
280
+ action: "deploy",
281
+ app: args.app,
282
+ dryRun: true,
283
+ commands: [`fly machine update <machine-id> --app ${args.app} --image ${args.image} --yes`]
284
+ };
285
+ }
286
+ const summary = provider.deploySeller(args);
287
+ return {
288
+ ok: true,
289
+ provider: "fly",
290
+ action: "deploy",
291
+ app: args.app,
292
+ dryRun: false,
293
+ summary: String(summary)
294
+ };
295
+ }
296
+
297
+ // -- remove --
298
+
299
+ public remove(appName: string, dryRun: boolean, json: boolean): SellerActionResult<"remove"> | string {
300
+ const provider = this.getProvider();
301
+ if (!json) {
302
+ return provider.removeSeller(appName, dryRun);
303
+ }
304
+ if (dryRun) {
305
+ return {
306
+ ok: true,
307
+ provider: "fly",
308
+ action: "remove",
309
+ app: appName,
310
+ dryRun: true,
311
+ commands: [`fly apps destroy ${appName} --yes`]
312
+ };
313
+ }
314
+ const summary = provider.removeSeller(appName, dryRun);
315
+ return {
316
+ ok: true,
317
+ provider: "fly",
318
+ action: "remove",
319
+ app: appName,
320
+ dryRun: false,
321
+ summary: String(summary)
322
+ };
323
+ }
324
+ }
325
+
326
+ // ===== helpers =====
327
+
328
+ /** 跟 FlyProvider 私有 flyEnv 行为一致; 复用 provider config token */
329
+ function flyEnv(config: SellerProviderConfig | undefined): NodeJS.ProcessEnv {
330
+ const configuredToken = config?.token;
331
+ const merged: NodeJS.ProcessEnv = { ...process.env };
332
+ if (configuredToken && !merged.FLY_API_TOKEN) {
333
+ merged.FLY_API_TOKEN = configuredToken;
334
+ }
335
+ return merged;
336
+ }
337
+
338
+ function providerCheck(flyctl: string): boolean {
339
+ try {
340
+ execSync(`which ${flyctl}`, { stdio: "ignore" });
341
+ return true;
342
+ } catch {
343
+ return false;
344
+ }
345
+ }
346
+
347
+ function buildCreateCommands(options: SellerCreateOptions): string[] {
348
+ const appName = options.app || `tb-seller-${options.name}`;
349
+ const region = options.region || "sin";
350
+ const lines: string[] = [];
351
+ if (options.flyConfig) {
352
+ lines.push(`fly apps create ${appName}`);
353
+ }
354
+ lines.push(`fly deploy --config ${options.flyConfig} --app ${appName} --region ${region}`);
355
+ if (options.image) {
356
+ lines.push(`fly machine update <machine-id> --app ${appName} --image ${options.image} --yes`);
357
+ }
358
+ return lines;
359
+ }
360
+
361
+ // re-exports 方便测试
362
+ export { parseFlyListJson, parseFlyListText, parseFlyStatusJson };
package/src/ui-actions.ts CHANGED
@@ -12,6 +12,12 @@ export interface UiActionResult {
12
12
  stdout: string;
13
13
  stderr: string;
14
14
  command: string[];
15
+ /**
16
+ * Step 13 (admin web -> CLI --json):
17
+ * 一些子命令 (`tb-admin seller <cmd> --json` 等) spawn 出来的 JSON.
18
+ * 1.0.32 行为 (无此字段) 不变; 新增字段不影响旧调用方.
19
+ */
20
+ json?: unknown;
15
21
  }
16
22
 
17
23
  export interface CreateSellerRequest {
@@ -172,7 +178,8 @@ export class UiActions {
172
178
  args.push("--dry-run");
173
179
  }
174
180
  report({ stepId: "create_deployment", status: "running", title: "Create Fly deployment", message: `Creating ${appName} in ${normalizedRequest.region}.` });
175
- const result = await this.runAdmin(globalArgs(this.options, args), 10 * 60 * 1000);
181
+ // Step 13: CLI --json 路径, admin web 拿到 result.json (结构化) + 1.0.32 result.stdout (兼容 UI)
182
+ const result = await this.runAdminJson(globalArgs(this.options, args), 10 * 60 * 1000);
176
183
  report({
177
184
  stepId: "create_deployment",
178
185
  status: result.ok ? "succeeded" : "failed",
@@ -254,20 +261,35 @@ export class UiActions {
254
261
  }
255
262
 
256
263
  public async setRegistryStatus(id: string, status: "active" | "draining" | "offline"): Promise<UiActionResult> {
264
+ // Step 14 (v1.1): tb-admin ui 现在走 vendor-scoped /platform/sellers/{id}/status.
265
+ // 老路径 `tb-admin bootstrap sellers status` (operator-level) 保留给 platform operator 显式调用.
266
+ // Vendor client 用当前 active profile 的 token, 跟 admin web 鉴权一致.
257
267
  const registry = await this.state.fetchRegistry();
258
268
  const target = registry.sellers.find((seller) => seller.id === id || seller.name === id || seller.app === id);
259
269
  if (!target) {
260
270
  throw new Error(`seller \`${id}\` not found in bootstrap registry`);
261
271
  }
262
- return this.runAdmin(globalArgs(this.options, [
263
- "bootstrap",
264
- "sellers",
265
- "status",
266
- target.id,
267
- status,
268
- "--expect-version",
269
- String(registry.version)
270
- ]), 30000);
272
+ const { RegistryVendorClient } = await import("./vendor-client.js");
273
+ const profile = (this.state as unknown as { activeBootstrapProfile(): { profile?: { url?: string; token?: string } } }).activeBootstrapProfile();
274
+ const baseUrl = this.options.url || profile.profile?.url;
275
+ const token = this.options.token || profile.profile?.token;
276
+ if (!baseUrl || !token) {
277
+ throw new Error("No bootstrap profile found. Configure an admin profile or pass --url and --token.");
278
+ }
279
+ const client = new RegistryVendorClient(baseUrl, token);
280
+ const idempotencyKey = `tb-admin-ui-${target.id}-${status}-${registry.version}-${Date.now()}`;
281
+ const response = await client.setSellerStatus(target.id, status, {
282
+ expectedVersion: registry.version,
283
+ idempotencyKey
284
+ });
285
+ const newVersion = (response as { registry?: { version?: number } })?.registry?.version ?? registry.version + 1;
286
+ return {
287
+ ok: true,
288
+ stdout: `Set seller ${target.id} status=${status}: version=${newVersion} (vendor path)`,
289
+ stderr: "",
290
+ command: ["PUT", `/platform/sellers/${target.id}/status`, JSON.stringify({ status, expectedVersion: registry.version })],
291
+ json: response
292
+ };
271
293
  }
272
294
 
273
295
  public async deleteDeployment(id: string, confirm: boolean): Promise<UiActionResult> {
@@ -281,7 +303,8 @@ export class UiActions {
281
303
  if (!confirm) {
282
304
  args.push("--dry-run");
283
305
  }
284
- return this.runAdmin(globalArgs(this.options, args), confirm ? 10 * 60 * 1000 : 30000);
306
+ // Step 13: CLI --json 路径拿结构化 result.json (UI 暂仍按 1.0.32 用 stdout/stderr)
307
+ return this.runAdminJson(globalArgs(this.options, args), confirm ? 10 * 60 * 1000 : 30000);
285
308
  }
286
309
 
287
310
  private async withTempYaml<T>(document: unknown, callback: (filePath: string) => Promise<T>): Promise<T> {
@@ -313,6 +336,22 @@ export class UiActions {
313
336
  return runTbAdmin(args, timeoutMs);
314
337
  }
315
338
 
339
+ /**
340
+ * Step 13: 跟 `runAdmin` 一样 spawn tb-admin, 但 args 末尾追加 `--json` 并解析.
341
+ * 1.0.32 `commandRunner` 注入路径 (test mock): 解析成功就挂 json, 解析失败时 result.json
342
+ * 保持 undefined 但 ok 不变 (mock 拼的 stdout 经常不是 JSON, 强制失败会破坏所有老 test).
343
+ * 生产 spawn 路径走 runTbAdminJson, 那里解析失败才算 ok=false (真的 CLI 异常).
344
+ */
345
+ private runAdminJson(args: string[], timeoutMs: number): Promise<UiActionResult> {
346
+ if (this.commandRunner) {
347
+ return this.commandRunner([...args, "--json"], timeoutMs).then((result) => {
348
+ const parsed = parseJsonSafely(result.stdout);
349
+ return parsed === undefined ? { ...result } : { ...result, json: parsed };
350
+ });
351
+ }
352
+ return runTbAdminJson(args, timeoutMs);
353
+ }
354
+
316
355
  private async waitForSellerReady(appName: string, operatorSecret: string, report: UiActionProgressReporter): Promise<UiActionResult> {
317
356
  const maxAttempts = 18;
318
357
  const delayMs = 5000;
@@ -590,6 +629,45 @@ export function runTbAdmin(args: string[], timeoutMs: number): Promise<UiActionR
590
629
  });
591
630
  }
592
631
 
632
+ /**
633
+ * Step 13 (admin web -> CLI --json):
634
+ * 跟 `runTbAdmin` 一样 spawn `tb-admin <args>`, **但 args 自动追加 `--json` flag**,
635
+ * 把 stdout 当 JSON 解析后挂在 result.json.
636
+ *
637
+ * 1.0.32 UI 调用方继续看 `result.stdout` / `result.ok` / `result.stderr`, 不破坏体验.
638
+ * UI 改造 (Step 14) 时改用 `result.json` 取结构化数据.
639
+ *
640
+ * 解析失败时 (CLI 输出不是 JSON, 比如老版本 CLI 没 --json), json 字段 = undefined,
641
+ * stdout 仍是原始字符串, ok=false (CLI exit 0 但 stdout 不可解析视为可恢复错).
642
+ */
643
+ export function runTbAdminJson(args: string[], timeoutMs: number): Promise<UiActionResult> {
644
+ const jsonArgs = [...args, "--json"];
645
+ return runTbAdmin(jsonArgs, timeoutMs).then((result) => {
646
+ const parsed = parseJsonSafely(result.stdout);
647
+ if (parsed === undefined) {
648
+ return {
649
+ ...result,
650
+ ok: false,
651
+ stderr: result.stderr
652
+ ? `${result.stderr}\n[Step 13] failed to parse --json output`
653
+ : `[Step 13] failed to parse --json output`
654
+ };
655
+ }
656
+ return { ...result, json: parsed };
657
+ });
658
+ }
659
+
660
+ function parseJsonSafely(text: string): unknown | undefined {
661
+ if (!text || text.trim().length === 0) {
662
+ return undefined;
663
+ }
664
+ try {
665
+ return JSON.parse(text);
666
+ } catch {
667
+ return undefined;
668
+ }
669
+ }
670
+
593
671
  async function defaultFetchJson(url: string, init?: RequestInit): Promise<unknown> {
594
672
  const response = await fetch(url, init);
595
673
  if (!response.ok) {
package/src/ui-server.ts CHANGED
@@ -101,6 +101,15 @@ async function routeRequest(
101
101
  sendJson(res, 200, await state.bootstrapConfig());
102
102
  return;
103
103
  }
104
+ // Step 6 (registry redesign): legacy Bootstrap tab now surfaces
105
+ // vendor release requests from the wallet-bootstrap vendor API.
106
+ // TODO: wire this to call the active profile's wallet-bootstrap
107
+ // baseUrl with a vendor Bearer token. For now we return an empty
108
+ // list so the UI renders without a runtime error.
109
+ if (req.method === "GET" && parsed.pathname === "/api/vendor/release-requests") {
110
+ sendJson(res, 200, { releaseRequests: [] });
111
+ return;
112
+ }
104
113
  if (req.method === "GET" && parsed.pathname === "/api/sellers") {
105
114
  sendJson(res, 200, await state.sellers());
106
115
  return;