@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.
- 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
package/src/provider.ts
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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;
|