@tokenbuddy/tb-admin 1.0.36 → 1.0.37

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 (93) hide show
  1. package/dist/src/cli.js +92 -19
  2. package/dist/src/config.d.ts +7 -1
  3. package/dist/src/config.js +16 -4
  4. package/dist/src/display-format.js +6 -14
  5. package/dist/src/init-command.d.ts +50 -0
  6. package/dist/src/init-command.js +347 -0
  7. package/dist/src/providers/fly-io.d.ts +3 -0
  8. package/dist/src/providers/fly-io.js +137 -0
  9. package/dist/src/providers/provider-definition.d.ts +38 -0
  10. package/dist/src/providers/provider-definition.js +2 -0
  11. package/dist/src/seller.d.ts +2 -0
  12. package/dist/src/seller.js +30 -13
  13. package/dist/src/server-cmd.d.ts +1 -0
  14. package/dist/src/server-cmd.js +9 -2
  15. package/dist/src/ui-actions.d.ts +3 -0
  16. package/dist/src/ui-actions.js +199 -27
  17. package/dist/src/ui-command.js +3 -2
  18. package/dist/src/ui-state.d.ts +1 -3
  19. package/dist/src/ui-state.js +4 -8
  20. package/dist/src/ui-static.js +43 -15
  21. package/dist/src/workdir.d.ts +21 -0
  22. package/dist/src/workdir.js +50 -0
  23. package/package.json +8 -2
  24. package/templates/providers/fly.io/admin.toml.example +18 -0
  25. package/templates/providers/fly.io/deploy-secrets/bootstrap/README.md +18 -0
  26. package/templates/providers/fly.io/deploy-secrets/bootstrap/admin-web.example.env +3 -0
  27. package/templates/providers/fly.io/deploy-secrets/bootstrap/cloudflare-r2.example.env +6 -0
  28. package/templates/providers/fly.io/deploy-secrets/bootstrap/registry-signing-key.example.json +6 -0
  29. package/templates/providers/fly.io/deploy-secrets/bootstrap/registry.example.json +14 -0
  30. package/templates/providers/fly.io/deploy-secrets/bootstrap/tb-registry.example.yaml +14 -0
  31. package/templates/providers/fly.io/deploy-secrets/seller-configs/README.md +13 -0
  32. package/templates/providers/fly.io/deploy-secrets/seller-configs/seller.example.yaml +35 -0
  33. package/templates/providers/fly.io/env/deploy.env.example +12 -0
  34. package/templates/providers/fly.io/fly/fly.tb-registry.toml +31 -0
  35. package/templates/providers/fly.io/fly/fly.tb-seller.toml +25 -0
  36. package/templates/providers/fly.io/provider.toml.example +10 -0
  37. package/dist/src/bootstrap-registry.d.ts.map +0 -1
  38. package/dist/src/bootstrap-registry.js.map +0 -1
  39. package/dist/src/cli.d.ts.map +0 -1
  40. package/dist/src/cli.js.map +0 -1
  41. package/dist/src/client.d.ts.map +0 -1
  42. package/dist/src/client.js.map +0 -1
  43. package/dist/src/config.d.ts.map +0 -1
  44. package/dist/src/config.js.map +0 -1
  45. package/dist/src/display-format.d.ts.map +0 -1
  46. package/dist/src/display-format.js.map +0 -1
  47. package/dist/src/index.d.ts.map +0 -1
  48. package/dist/src/index.js.map +0 -1
  49. package/dist/src/provider.d.ts.map +0 -1
  50. package/dist/src/provider.js.map +0 -1
  51. package/dist/src/seller.d.ts.map +0 -1
  52. package/dist/src/seller.js.map +0 -1
  53. package/dist/src/server-cmd.d.ts.map +0 -1
  54. package/dist/src/server-cmd.js.map +0 -1
  55. package/dist/src/ui-actions.d.ts.map +0 -1
  56. package/dist/src/ui-actions.js.map +0 -1
  57. package/dist/src/ui-command.d.ts.map +0 -1
  58. package/dist/src/ui-command.js.map +0 -1
  59. package/dist/src/ui-server.d.ts.map +0 -1
  60. package/dist/src/ui-server.js.map +0 -1
  61. package/dist/src/ui-state.d.ts.map +0 -1
  62. package/dist/src/ui-state.js.map +0 -1
  63. package/dist/src/ui-static.d.ts.map +0 -1
  64. package/dist/src/ui-static.js.map +0 -1
  65. package/dist/src/upstream-balance-probe.d.ts.map +0 -1
  66. package/dist/src/upstream-balance-probe.js.map +0 -1
  67. package/dist/src/vendor-client.d.ts.map +0 -1
  68. package/dist/src/vendor-client.js.map +0 -1
  69. package/dist/src/vendor-commands.d.ts.map +0 -1
  70. package/dist/src/vendor-commands.js.map +0 -1
  71. package/src/bootstrap-registry.ts +0 -90
  72. package/src/cli.ts +0 -1614
  73. package/src/client.ts +0 -179
  74. package/src/config.ts +0 -194
  75. package/src/display-format.ts +0 -411
  76. package/src/index.ts +0 -11
  77. package/src/provider.ts +0 -150
  78. package/src/seller.ts +0 -538
  79. package/src/server-cmd.ts +0 -362
  80. package/src/ui-actions.ts +0 -1040
  81. package/src/ui-command.ts +0 -44
  82. package/src/ui-server.ts +0 -353
  83. package/src/ui-state.ts +0 -1318
  84. package/src/ui-static.ts +0 -673
  85. package/src/upstream-balance-probe.ts +0 -13
  86. package/src/vendor-client.ts +0 -23
  87. package/src/vendor-commands.ts +0 -65
  88. package/tests/admin.test.ts +0 -2162
  89. package/tests/seller.test.ts +0 -388
  90. package/tests/ui-state-fleet.test.ts +0 -526
  91. package/tests/ui-static-row.test.ts +0 -467
  92. package/tests/vendor-cli.test.ts +0 -241
  93. package/tsconfig.json +0 -8
@@ -0,0 +1,3 @@
1
+ import type { AdminProviderDefinition } from "./provider-definition.js";
2
+ export declare const flyIoProviderDefinition: AdminProviderDefinition;
3
+ //# sourceMappingURL=fly-io.d.ts.map
@@ -0,0 +1,137 @@
1
+ function commandOk(ctx, command, args) {
2
+ const result = ctx.spawnSync(command, args, {
3
+ encoding: "utf8",
4
+ env: ctx.env,
5
+ stdio: ["ignore", "pipe", "pipe"]
6
+ });
7
+ return !result.error && result.status === 0;
8
+ }
9
+ function flyctlCandidates(ctx) {
10
+ const configured = ctx.flyctlPath ? [ctx.flyctlPath] : [];
11
+ return [...configured, "flyctl", "fly"].filter((item, index, all) => all.indexOf(item) === index);
12
+ }
13
+ function findFlyctl(ctx) {
14
+ return flyctlCandidates(ctx).find((candidate) => commandOk(ctx, candidate, ["version"]));
15
+ }
16
+ function flyCliCheck(ctx) {
17
+ const flyctl = findFlyctl(ctx);
18
+ if (flyctl) {
19
+ return {
20
+ id: "fly",
21
+ label: "fly",
22
+ status: "ok",
23
+ message: `${flyctl} is available`
24
+ };
25
+ }
26
+ return {
27
+ id: "fly",
28
+ label: "fly",
29
+ status: "missing",
30
+ message: "Fly.io CLI is missing",
31
+ command: "curl -fsSL https://fly.io/install.sh | sh"
32
+ };
33
+ }
34
+ function flyAuthCheck(ctx) {
35
+ if (ctx.env.FLY_API_TOKEN || ctx.env.FLY_ACCESS_TOKEN) {
36
+ return {
37
+ id: "fly-auth",
38
+ label: "fly auth",
39
+ status: "ok",
40
+ message: "Fly auth token is present in the environment"
41
+ };
42
+ }
43
+ const flyctl = findFlyctl(ctx);
44
+ if (flyctl && commandOk(ctx, flyctl, ["auth", "whoami"])) {
45
+ return {
46
+ id: "fly-auth",
47
+ label: "fly auth",
48
+ status: "ok",
49
+ message: "fly auth whoami succeeded"
50
+ };
51
+ }
52
+ return {
53
+ id: "fly-auth",
54
+ label: "fly auth",
55
+ status: "missing",
56
+ message: "Fly auth is missing",
57
+ command: "fly auth login, or set FLY_API_TOKEN"
58
+ };
59
+ }
60
+ function dockerCheck(ctx) {
61
+ if (commandOk(ctx, "docker", ["info"])) {
62
+ return {
63
+ id: "docker",
64
+ label: "docker",
65
+ status: "ok",
66
+ message: "Docker daemon is available"
67
+ };
68
+ }
69
+ return {
70
+ id: "docker",
71
+ label: "docker",
72
+ status: "missing",
73
+ message: "Docker daemon is not available",
74
+ command: "Start Docker Desktop or your Docker daemon"
75
+ };
76
+ }
77
+ function buildxCheck(ctx) {
78
+ if (commandOk(ctx, "docker", ["buildx", "version"])) {
79
+ return {
80
+ id: "buildx",
81
+ label: "buildx",
82
+ status: "ok",
83
+ message: "Docker buildx is available"
84
+ };
85
+ }
86
+ return {
87
+ id: "buildx",
88
+ label: "buildx",
89
+ status: "missing",
90
+ message: "Docker buildx is not available",
91
+ command: "Install or upgrade Docker Desktop"
92
+ };
93
+ }
94
+ function installFlyCli(ctx) {
95
+ const command = "curl -fsSL https://fly.io/install.sh | sh";
96
+ if (ctx.dryRun) {
97
+ return { ok: true, message: command };
98
+ }
99
+ console.log(`Installing Fly.io CLI with: ${command}`);
100
+ const result = ctx.spawnSync("sh", ["-c", command], {
101
+ encoding: "utf8",
102
+ env: ctx.env,
103
+ stdio: "inherit"
104
+ });
105
+ if (result.error) {
106
+ return { ok: false, message: result.error.message };
107
+ }
108
+ if (result.status !== 0) {
109
+ return { ok: false, message: `Fly.io CLI install exited with code ${result.status}` };
110
+ }
111
+ return {
112
+ ok: true,
113
+ message: "Fly.io CLI install finished. If fly is still not found, run: export PATH=\"$HOME/.fly/bin:$PATH\""
114
+ };
115
+ }
116
+ export const flyIoProviderDefinition = {
117
+ id: "fly.io",
118
+ displayName: "Fly.io",
119
+ templateRelativeRoot: "templates/providers/fly.io",
120
+ defaultPaths: {
121
+ providerConfig: "providers/fly.io.toml",
122
+ sellerFlyConfig: "fly/fly.tb-seller.toml",
123
+ registryFlyConfig: "fly/fly.tb-registry.toml",
124
+ deployEnv: "env/deploy.env",
125
+ artifactsDir: "artifacts"
126
+ },
127
+ check(ctx) {
128
+ return [
129
+ flyCliCheck(ctx),
130
+ flyAuthCheck(ctx),
131
+ dockerCheck(ctx),
132
+ buildxCheck(ctx)
133
+ ];
134
+ },
135
+ install: installFlyCli
136
+ };
137
+ //# sourceMappingURL=fly-io.js.map
@@ -0,0 +1,38 @@
1
+ import type { SpawnSyncOptions, SpawnSyncReturns } from "child_process";
2
+ export type AdminProviderId = "fly.io";
3
+ export type ProviderCheckStatus = "ok" | "missing" | "warning";
4
+ export type SpawnSyncRunner = (command: string, args?: string[], options?: SpawnSyncOptions) => SpawnSyncReturns<string | Buffer>;
5
+ export interface ProviderCheckContext {
6
+ env: NodeJS.ProcessEnv;
7
+ spawnSync: SpawnSyncRunner;
8
+ flyctlPath?: string;
9
+ }
10
+ export interface ProviderInstallContext extends ProviderCheckContext {
11
+ dryRun?: boolean;
12
+ }
13
+ export interface ProviderCheckResult {
14
+ id: string;
15
+ label: string;
16
+ status: ProviderCheckStatus;
17
+ message: string;
18
+ command?: string;
19
+ }
20
+ export interface ProviderInstallResult {
21
+ ok: boolean;
22
+ message: string;
23
+ }
24
+ export interface AdminProviderDefinition {
25
+ id: AdminProviderId;
26
+ displayName: string;
27
+ templateRelativeRoot: string;
28
+ defaultPaths: {
29
+ providerConfig: string;
30
+ sellerFlyConfig: string;
31
+ registryFlyConfig: string;
32
+ deployEnv: string;
33
+ artifactsDir: string;
34
+ };
35
+ check(ctx: ProviderCheckContext): ProviderCheckResult[];
36
+ install?(ctx: ProviderInstallContext): ProviderInstallResult;
37
+ }
38
+ //# sourceMappingURL=provider-definition.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=provider-definition.js.map
@@ -121,8 +121,10 @@ export declare class SellerCommandRunner {
121
121
  private configManager;
122
122
  constructor(configManager: ConfigManager);
123
123
  private getProviderConfig;
124
+ private getWorkdir;
124
125
  private getProvider;
125
126
  private getFlyctl;
127
+ private resolveCreateOptions;
126
128
  ls(json: boolean): SellerListResult | string;
127
129
  status(appName: string, json: boolean): SellerStatusResult | string;
128
130
  machineSpecs(appName: string): SellerMachineSpecs | undefined;
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { execSync, spawnSync } from "node:child_process";
22
22
  import { FlyProvider } from "./server-cmd.js";
23
+ import { resolveWorkdirPath } from "./workdir.js";
23
24
  export class FlyCliMissingError extends Error {
24
25
  flyctl;
25
26
  constructor(flyctl) {
@@ -158,12 +159,31 @@ export class SellerCommandRunner {
158
159
  getProviderConfig() {
159
160
  return this.configManager.getSellerProvider("fly");
160
161
  }
162
+ getWorkdir() {
163
+ return this.configManager.getWorkdir();
164
+ }
161
165
  getProvider() {
162
166
  return new FlyProvider(this.getProviderConfig());
163
167
  }
164
168
  getFlyctl() {
165
169
  return this.getProviderConfig()?.flyctl_path || "flyctl";
166
170
  }
171
+ resolveCreateOptions(options) {
172
+ const workdir = this.getWorkdir();
173
+ const providerConfig = this.getProviderConfig();
174
+ const flyConfig = resolveWorkdirPath(workdir, options.flyConfig || "fly/fly.tb-seller.toml");
175
+ const initialConfigPath = options.initialConfigPath
176
+ ? resolveWorkdirPath(workdir, options.initialConfigPath)
177
+ : providerConfig?.default_config
178
+ ? resolveWorkdirPath(workdir, providerConfig.default_config)
179
+ : undefined;
180
+ return {
181
+ ...options,
182
+ flyConfig,
183
+ initialConfigPath,
184
+ resolvedWorkdir: workdir
185
+ };
186
+ }
167
187
  // -- ls --
168
188
  ls(json) {
169
189
  const provider = this.getProvider();
@@ -207,27 +227,28 @@ export class SellerCommandRunner {
207
227
  // -- create --
208
228
  create(options, json) {
209
229
  const provider = this.getProvider();
230
+ const resolvedOptions = this.resolveCreateOptions(options);
210
231
  if (!json) {
211
- return provider.createSeller(options);
232
+ return provider.createSeller(resolvedOptions);
212
233
  }
213
234
  // --json 路径: dry-run 模式直接列命令, 不实际调 flyctl
214
- if (options.dryRun) {
235
+ if (resolvedOptions.dryRun) {
215
236
  return {
216
237
  ok: true,
217
238
  provider: "fly",
218
239
  action: "create",
219
- app: options.app || `tb-seller-${options.name}`,
240
+ app: resolvedOptions.app || `tb-seller-${resolvedOptions.name}`,
220
241
  dryRun: true,
221
- commands: buildCreateCommands(options)
242
+ commands: buildCreateCommands(resolvedOptions)
222
243
  };
223
244
  }
224
245
  // 非 dry-run: 调 FlyProvider (它已经会跑 flyctl), 包装 stdout
225
- const summary = provider.createSeller(options);
246
+ const summary = provider.createSeller(resolvedOptions);
226
247
  return {
227
248
  ok: true,
228
249
  provider: "fly",
229
250
  action: "create",
230
- app: options.app || `tb-seller-${options.name}`,
251
+ app: resolvedOptions.app || `tb-seller-${resolvedOptions.name}`,
231
252
  dryRun: false,
232
253
  summary: String(summary)
233
254
  };
@@ -399,13 +420,9 @@ function buildCreateCommands(options) {
399
420
  const appName = options.app || `tb-seller-${options.name}`;
400
421
  const region = options.region || "sin";
401
422
  const lines = [];
402
- if (options.flyConfig) {
403
- lines.push(`fly apps create ${appName}`);
404
- }
405
- lines.push(`fly deploy --config ${options.flyConfig} --app ${appName} --region ${region}`);
406
- if (options.image) {
407
- lines.push(`fly machine update <machine-id> --app ${appName} --image ${options.image} --yes`);
408
- }
423
+ lines.push(`fly apps create ${appName}`);
424
+ lines.push(`fly secrets import --stage --app ${appName}`);
425
+ lines.push(`fly deploy --config ${options.flyConfig} --image ${options.image} --app ${appName} --primary-region ${region} --now`);
409
426
  return lines;
410
427
  }
411
428
  // re-exports 方便测试
@@ -40,6 +40,7 @@ export interface SellerCreateOptions {
40
40
  volumeId?: string;
41
41
  volumeSnapshotRetentionDays?: number;
42
42
  initialConfigPath?: string;
43
+ resolvedWorkdir?: string;
43
44
  dryRun?: boolean;
44
45
  }
45
46
  /**
@@ -150,7 +150,8 @@ export class FlyProvider {
150
150
  throw new Error("seller create requires --image registry.fly.io/tb-seller:<v>");
151
151
  }
152
152
  if (!flyConfig) {
153
- throw new Error("seller create requires --fly-config deploy/fly.io/fly.tb-seller.toml");
153
+ const suffix = options.resolvedWorkdir ? ` --workdir ${options.resolvedWorkdir}` : "";
154
+ throw new Error(`Fly config not found. Run: tb-admin init --provider fly.io${suffix}`);
154
155
  }
155
156
  if (dryRun) {
156
157
  const lines = [
@@ -176,7 +177,13 @@ export class FlyProvider {
176
177
  if (!operatorSecret) {
177
178
  throw new Error("operator_secret is required. Provide --operator-secret or configure seller_providers.fly.operator_secret");
178
179
  }
179
- requireReadableFile(flyConfig, "Fly config");
180
+ try {
181
+ requireReadableFile(flyConfig, "Fly config");
182
+ }
183
+ catch (err) {
184
+ const suffix = options.resolvedWorkdir ? ` --workdir ${options.resolvedWorkdir}` : "";
185
+ throw new Error(`${err.message}. Run: tb-admin init --provider fly.io${suffix}`);
186
+ }
180
187
  if (initialConfigPath) {
181
188
  requireReadableFile(initialConfigPath, "Initial seller config");
182
189
  }
@@ -18,6 +18,7 @@ export interface CreateSellerRequest {
18
18
  upstreamWebsite: string;
19
19
  upstreamUrl: string;
20
20
  upstreamApiKey: string;
21
+ upstreamProtocolPreset?: string;
21
22
  upstreamBalanceProbeTemplate?: string;
22
23
  upstreamBalanceProbeUrl?: string;
23
24
  upstreamBalanceProbeUserId?: string;
@@ -97,6 +98,7 @@ export declare class UiActions {
97
98
  private waitForSellerReady;
98
99
  private refreshSellerModelsWithRetry;
99
100
  private publishCreatedSellerRegistryEntry;
101
+ private submitCreatedSellerRelease;
100
102
  private fetchSellerOperatorJson;
101
103
  private fetchSellerOperatorJsonOptional;
102
104
  }
@@ -113,5 +115,6 @@ export declare function runTbAdmin(args: string[], timeoutMs: number): Promise<U
113
115
  * stdout 仍是原始字符串, ok=false (CLI exit 0 但 stdout 不可解析视为可恢复错).
114
116
  */
115
117
  export declare function runTbAdminJson(args: string[], timeoutMs: number): Promise<UiActionResult>;
118
+ export declare function parseJsonSafely(text: string): unknown | undefined;
116
119
  export {};
117
120
  //# sourceMappingURL=ui-actions.d.ts.map
@@ -50,9 +50,6 @@ export class UiActions {
50
50
  if (!operatorSecret) {
51
51
  throw new Error("operatorSecret is required in local admin config seller_providers.fly.operator_secret or request body");
52
52
  }
53
- if (!flyConfig) {
54
- throw new Error("flyConfig is required before creating a seller deployment");
55
- }
56
53
  const configRequest = {
57
54
  ...normalizedRequest,
58
55
  operatorSecret
@@ -86,13 +83,14 @@ export class UiActions {
86
83
  normalizedRequest.region,
87
84
  "--image",
88
85
  normalizedRequest.image,
89
- "--fly-config",
90
- flyConfig,
91
86
  "--initial-config",
92
87
  filePath,
93
88
  "--operator-secret",
94
89
  operatorSecret
95
90
  ];
91
+ if (flyConfig) {
92
+ args.push("--fly-config", flyConfig);
93
+ }
96
94
  if (normalizedRequest.app) {
97
95
  args.push("--app", normalizedRequest.app);
98
96
  }
@@ -388,25 +386,91 @@ export class UiActions {
388
386
  });
389
387
  return failed;
390
388
  }
391
- return this.withTempJson(entry, async (filePath) => {
392
- const put = await this.runAdmin(globalArgs(this.options, [
393
- "bootstrap",
394
- "sellers",
395
- "add",
396
- "--file",
397
- filePath,
398
- "--expect-version",
399
- String(registry.version)
400
- ]), 30000);
401
- report({
402
- stepId: "publish_registry",
403
- status: put.ok ? "succeeded" : "failed",
404
- title: "Update bootstrap registry",
405
- message: put.ok ? "Bootstrap registry entry was added. Run registry publish to update R2." : "Bootstrap registry update failed.",
406
- result: put
407
- });
408
- return put;
389
+ const submitted = await this.submitCreatedSellerRelease(entry);
390
+ report({
391
+ stepId: "publish_registry",
392
+ status: submitted.ok ? "succeeded" : "failed",
393
+ title: "Submit registry release",
394
+ message: submitted.ok ? "Seller was staged and a registry release request was submitted." : "Registry release request failed.",
395
+ result: submitted
409
396
  });
397
+ return submitted;
398
+ }
399
+ async submitCreatedSellerRelease(entry) {
400
+ const profile = this.state.activeBootstrapProfile();
401
+ const baseUrl = this.options.url || profile.profile?.url;
402
+ const token = this.options.token || profile.profile?.token;
403
+ const command = ["POST", "/platform/sellers/stage", "POST", "/platform/release-requests"];
404
+ if (!baseUrl || !token) {
405
+ return {
406
+ ok: false,
407
+ stdout: "",
408
+ stderr: "No bootstrap vendor profile found. Configure a bootstrap-vendor profile or pass --url and --token.",
409
+ command
410
+ };
411
+ }
412
+ const fetchJson = this.options.fetchJson || defaultFetchJson;
413
+ const headers = {
414
+ "Content-Type": "application/json",
415
+ Authorization: `Bearer ${token}`
416
+ };
417
+ try {
418
+ const stage = await fetchJson(`${trimSlash(baseUrl)}/platform/sellers/stage`, {
419
+ method: "POST",
420
+ headers,
421
+ body: JSON.stringify({ seller: entry })
422
+ });
423
+ const stagedSellerId = stagedSellerIdFromResponse(stage) || entry.id;
424
+ const release = await fetchJson(`${trimSlash(baseUrl)}/platform/release-requests`, {
425
+ method: "POST",
426
+ headers,
427
+ body: JSON.stringify({
428
+ stagedSellerIds: [stagedSellerId],
429
+ note: `tb-admin ui create seller ${entry.id}`
430
+ })
431
+ });
432
+ const releaseId = releaseRequestIdFromResponse(release);
433
+ if (releaseId === undefined) {
434
+ return {
435
+ ok: false,
436
+ stdout: "",
437
+ stderr: "Registry release request did not return an id.",
438
+ command
439
+ };
440
+ }
441
+ const published = await waitForSubmittedRelease({
442
+ baseUrl,
443
+ headers,
444
+ fetchJson,
445
+ releaseId,
446
+ sellerId: stagedSellerId
447
+ });
448
+ if (!published.ok) {
449
+ return {
450
+ ok: false,
451
+ stdout: "",
452
+ stderr: published.error,
453
+ command: [...command, "GET", "/platform/release-requests/:id", "GET", "/platform/sellers"],
454
+ json: { stage, release, stagedSellerId, publishStatus: published }
455
+ };
456
+ }
457
+ const json = { stage, release, stagedSellerId, published };
458
+ return {
459
+ ok: true,
460
+ stdout: JSON.stringify(releaseSummary(json), null, 2),
461
+ stderr: "",
462
+ command: [...command, "GET", "/platform/release-requests/:id", "GET", "/platform/sellers"],
463
+ json
464
+ };
465
+ }
466
+ catch (err) {
467
+ return {
468
+ ok: false,
469
+ stdout: "",
470
+ stderr: redactSensitive(err.message || "registry release request failed"),
471
+ command
472
+ };
473
+ }
410
474
  }
411
475
  async fetchSellerOperatorJson(appName, operatorSecret, pathName) {
412
476
  const fetchJson = this.options.fetchJson || defaultFetchJson;
@@ -544,16 +608,31 @@ export function runTbAdminJson(args, timeoutMs) {
544
608
  return { ...result, json: parsed };
545
609
  });
546
610
  }
547
- function parseJsonSafely(text) {
611
+ export function parseJsonSafely(text) {
548
612
  if (!text || text.trim().length === 0) {
549
613
  return undefined;
550
614
  }
615
+ const trimmed = text.trim();
551
616
  try {
552
- return JSON.parse(text);
617
+ return JSON.parse(trimmed);
553
618
  }
554
619
  catch {
555
- return undefined;
620
+ // Some flyctl-backed commands write human progress logs before the final
621
+ // structured JSON object. Keep the JSON path tolerant of that stdout shape.
622
+ for (let index = trimmed.length - 1; index >= 0; index -= 1) {
623
+ const char = trimmed[index];
624
+ if (char !== "{" && char !== "[") {
625
+ continue;
626
+ }
627
+ try {
628
+ return JSON.parse(trimmed.slice(index));
629
+ }
630
+ catch {
631
+ // Keep walking backward until the outer JSON value is found.
632
+ }
633
+ }
556
634
  }
635
+ return undefined;
557
636
  }
558
637
  async function defaultFetchJson(url, init) {
559
638
  const response = await fetch(url, init);
@@ -663,7 +742,7 @@ function normalizeCreateSellerRequest(request) {
663
742
  ...request,
664
743
  app,
665
744
  image: stringValue(request.image) || "registry.fly.io/tb-seller:latest",
666
- flyConfig: stringValue(request.flyConfig) || "deploy/fly.io/fly.tb-seller.toml",
745
+ flyConfig: stringValue(request.flyConfig) || undefined,
667
746
  upstreamUrl
668
747
  };
669
748
  if (paymentMethodsFromRequest(normalized).includes("clawtip")) {
@@ -688,6 +767,7 @@ function initialSellerConfig(request, masked) {
688
767
  upstreamUrl: request.upstreamUrl,
689
768
  upstreamApiKey: masked ? "********" : request.upstreamApiKey,
690
769
  upstreamWebsite: request.upstreamWebsite,
770
+ upstreamCapabilities: upstreamCapabilitiesFromPreset(request.upstreamProtocolPreset),
691
771
  upstreamBalanceUrl,
692
772
  upstreamUserId,
693
773
  upstreamRechargeUrl,
@@ -700,6 +780,26 @@ function initialSellerConfig(request, masked) {
700
780
  ...paymentConfig
701
781
  };
702
782
  }
783
+ function upstreamCapabilitiesFromPreset(value) {
784
+ const preset = stringValue(value) || "auto";
785
+ if (preset === "image") {
786
+ return {
787
+ chatCompletions: "unsupported",
788
+ responses: "unsupported",
789
+ messages: "unsupported",
790
+ imagesGenerations: "supported"
791
+ };
792
+ }
793
+ if (preset === "chat") {
794
+ return {
795
+ chatCompletions: "supported",
796
+ responses: "unsupported",
797
+ messages: "unsupported",
798
+ imagesGenerations: "unsupported"
799
+ };
800
+ }
801
+ return undefined;
802
+ }
703
803
  function paymentConfigFromRequest(request, masked) {
704
804
  const paymentMethods = paymentMethodsFromRequest(request);
705
805
  const config = {
@@ -860,6 +960,78 @@ function hostName(value) {
860
960
  function sellerOperatorUrl(appName) {
861
961
  return `https://${appName}.fly.dev`;
862
962
  }
963
+ function trimSlash(value) {
964
+ return String(value || "").replace(/\/+$/, "");
965
+ }
966
+ function stagedSellerIdFromResponse(value) {
967
+ const root = objectValue(value);
968
+ const pending = objectValue(root?.pendingSeller);
969
+ return stringValue(pending?.id);
970
+ }
971
+ function releaseRequestIdFromResponse(value) {
972
+ const root = objectValue(value);
973
+ const release = objectValue(root?.releaseRequest);
974
+ const id = release?.id;
975
+ return typeof id === "number" || typeof id === "string" ? id : undefined;
976
+ }
977
+ function releaseRequestStatusFromResponse(value) {
978
+ const root = objectValue(value);
979
+ const release = objectValue(root?.releaseRequest);
980
+ return stringValue(release?.status);
981
+ }
982
+ async function waitForSubmittedRelease(options) {
983
+ const deadline = Date.now() + 60000;
984
+ let lastRelease;
985
+ let lastSellers;
986
+ let lastStatus = "unknown";
987
+ while (Date.now() < deadline) {
988
+ lastRelease = await options.fetchJson(`${trimSlash(options.baseUrl)}/platform/release-requests/${encodeURIComponent(String(options.releaseId))}`, {
989
+ headers: options.headers
990
+ });
991
+ lastStatus = releaseRequestStatusFromResponse(lastRelease) || "unknown";
992
+ if (lastStatus === "rejected" || lastStatus === "failed") {
993
+ return {
994
+ ok: false,
995
+ error: `Registry release request ${options.releaseId} ended with status ${lastStatus}.`,
996
+ release: lastRelease,
997
+ sellers: lastSellers
998
+ };
999
+ }
1000
+ lastSellers = await options.fetchJson(`${trimSlash(options.baseUrl)}/platform/sellers`, {
1001
+ headers: options.headers
1002
+ });
1003
+ if (lastStatus === "published" && sellerRegistryContains(lastSellers, options.sellerId)) {
1004
+ return { ok: true, release: lastRelease, sellers: lastSellers };
1005
+ }
1006
+ await sleep(1000);
1007
+ }
1008
+ const sellerPart = sellerRegistryContains(lastSellers, options.sellerId)
1009
+ ? "seller is visible"
1010
+ : "seller is not visible";
1011
+ return {
1012
+ ok: false,
1013
+ error: `Registry release request ${options.releaseId} was not published before timeout (last status: ${lastStatus}; ${sellerPart}).`,
1014
+ release: lastRelease,
1015
+ sellers: lastSellers
1016
+ };
1017
+ }
1018
+ function sellerRegistryContains(value, sellerId) {
1019
+ const root = objectValue(value);
1020
+ const sellers = Array.isArray(root?.sellers) ? root.sellers : Array.isArray(value) ? value : [];
1021
+ return sellers.some((seller) => {
1022
+ const entry = objectValue(seller);
1023
+ return entry?.id === sellerId || entry?.name === sellerId || entry?.app === sellerId;
1024
+ });
1025
+ }
1026
+ function releaseSummary(value) {
1027
+ return {
1028
+ ok: true,
1029
+ action: "submit_registry_release",
1030
+ stagedSellerId: value.stagedSellerId,
1031
+ releaseRequestId: releaseRequestIdFromResponse(value.release),
1032
+ releaseStatus: releaseRequestStatusFromResponse(value.published?.release || value.release)
1033
+ };
1034
+ }
863
1035
  function sleep(ms) {
864
1036
  return new Promise((resolve) => setTimeout(resolve, ms));
865
1037
  }
@@ -10,13 +10,14 @@ export function bindAdminUiCommand(program, configManager) {
10
10
  .action(async (options) => {
11
11
  try {
12
12
  const rootOptions = program.opts();
13
- const mgr = rootOptions.config ? new ConfigManager(rootOptions.config) : configManager;
13
+ const mgr = rootOptions.config || rootOptions.workdir
14
+ ? new ConfigManager(rootOptions.config, { cliWorkdir: rootOptions.workdir })
15
+ : configManager;
14
16
  const started = await startAdminUiServer({
15
17
  host: options.host,
16
18
  port: options.port,
17
19
  openBrowser: Boolean(options.open),
18
20
  configManager: mgr,
19
- configPath: rootOptions.config,
20
21
  profile: rootOptions.profile || process.env.TOKENBUDDY_ADMIN_PROFILE || defaultUiProfile(mgr),
21
22
  url: rootOptions.url || process.env.TOKENBUDDY_ADMIN_URL,
22
23
  token: rootOptions.token || process.env.TOKENBUDDY_ADMIN_TOKEN
@@ -60,7 +60,7 @@ export interface SellerRow {
60
60
  error?: string;
61
61
  /**
62
62
  * Step 13 v1.1: 数据源标记. UI 据此决定:
63
- * - "fly" → 灰点 + 「未发布」+ Apply 按钮 (走 vendor-bootstrap stage)
63
+ * - "fly" → 灰点 + 「未发布」
64
64
  * - "registry" → 整行标红 + 立即下线按钮 (registry-only = 重大事故)
65
65
  * - "both" → 正常色 + Activate / Drain 走 vendor path
66
66
  */
@@ -72,8 +72,6 @@ export interface SellerRow {
72
72
  registryAlert?: boolean;
73
73
  /** Step 13 v1.1: 标红原因 (中文短句, UI tooltip 显示). */
74
74
  alertReason?: string;
75
- /** Step 13 v1.1: "未发布" 提示 (fly-only 行的 publishHint 按钮 caption). */
76
- publishHint?: string;
77
75
  /**
78
76
  * Step 13 v1.1: 立即下线按钮 caption (registry-only 行). 文案
79
77
  * 必须含 "registry-only" 让用户知道这**不**删 fly app. 详见 spec.