@tokenbuddy/tb-admin 1.0.13 → 1.0.15

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 (45) hide show
  1. package/dist/src/bootstrap-registry.d.ts +1 -0
  2. package/dist/src/bootstrap-registry.d.ts.map +1 -1
  3. package/dist/src/bootstrap-registry.js.map +1 -1
  4. package/dist/src/cli.d.ts.map +1 -1
  5. package/dist/src/cli.js +30 -16
  6. package/dist/src/cli.js.map +1 -1
  7. package/dist/src/server-cmd.d.ts +27 -2
  8. package/dist/src/server-cmd.d.ts.map +1 -1
  9. package/dist/src/server-cmd.js +131 -26
  10. package/dist/src/server-cmd.js.map +1 -1
  11. package/dist/src/ui-actions.d.ts +88 -0
  12. package/dist/src/ui-actions.d.ts.map +1 -0
  13. package/dist/src/ui-actions.js +763 -0
  14. package/dist/src/ui-actions.js.map +1 -0
  15. package/dist/src/ui-command.d.ts +4 -0
  16. package/dist/src/ui-command.d.ts.map +1 -0
  17. package/dist/src/ui-command.js +37 -0
  18. package/dist/src/ui-command.js.map +1 -0
  19. package/dist/src/ui-server.d.ts +23 -0
  20. package/dist/src/ui-server.d.ts.map +1 -0
  21. package/dist/src/ui-server.js +245 -0
  22. package/dist/src/ui-server.js.map +1 -0
  23. package/dist/src/ui-state.d.ts +134 -0
  24. package/dist/src/ui-state.d.ts.map +1 -0
  25. package/dist/src/ui-state.js +407 -0
  26. package/dist/src/ui-state.js.map +1 -0
  27. package/dist/src/ui-static.d.ts +2 -0
  28. package/dist/src/ui-static.d.ts.map +1 -0
  29. package/dist/src/ui-static.js +144 -0
  30. package/dist/src/ui-static.js.map +1 -0
  31. package/dist/src/upstream-balance-probe.d.ts +41 -0
  32. package/dist/src/upstream-balance-probe.d.ts.map +1 -0
  33. package/dist/src/upstream-balance-probe.js +379 -0
  34. package/dist/src/upstream-balance-probe.js.map +1 -0
  35. package/package.json +1 -1
  36. package/src/bootstrap-registry.ts +1 -0
  37. package/src/cli.ts +32 -16
  38. package/src/server-cmd.ts +163 -29
  39. package/src/ui-actions.ts +901 -0
  40. package/src/ui-command.ts +39 -0
  41. package/src/ui-server.ts +308 -0
  42. package/src/ui-state.ts +575 -0
  43. package/src/ui-static.ts +144 -0
  44. package/src/upstream-balance-probe.ts +505 -0
  45. package/tests/admin.test.ts +893 -4
package/src/server-cmd.ts CHANGED
@@ -1,6 +1,25 @@
1
- import { execSync } from "child_process";
1
+ import { execSync, spawnSync, type SpawnSyncReturns } from "child_process";
2
+ import * as fs from "fs";
2
3
  import { SellerProviderConfig } from "./config.js";
3
4
 
5
+ type ExecRunner = (command: string, options?: Parameters<typeof execSync>[1]) => string | Buffer;
6
+ type SpawnRunner = (command: string, args?: string[], options?: Parameters<typeof spawnSync>[2]) => SpawnSyncReturns<string | Buffer>;
7
+
8
+ export interface DockerImageInspection {
9
+ ok: boolean;
10
+ error?: string;
11
+ exitCode?: number;
12
+ }
13
+
14
+ export type ImageInspectRunner = (image: string) => DockerImageInspection;
15
+
16
+ export interface FlyProviderRuntime {
17
+ checkFlyctlInstalled?: (flyctlPath?: string) => boolean;
18
+ execSync?: ExecRunner;
19
+ spawnSync?: SpawnRunner;
20
+ imageInspector?: ImageInspectRunner;
21
+ }
22
+
4
23
  /**
5
24
  * 检查 flyctl 是否在 PATH 中(或在指定路径)。
6
25
  *
@@ -16,6 +35,70 @@ export function checkFlyctlInstalled(flyctlPath?: string): boolean {
16
35
  }
17
36
  }
18
37
 
38
+ export function inspectDockerImage(image: string): DockerImageInspection {
39
+ const result = spawnSync("docker", ["buildx", "imagetools", "inspect", image], {
40
+ encoding: "utf8",
41
+ stdio: ["ignore", "pipe", "pipe"]
42
+ });
43
+ return dockerImageInspectionFromResult(result);
44
+ }
45
+
46
+ export function requirePublishedDockerImage(image: string, inspectImage: ImageInspectRunner = inspectDockerImage): void {
47
+ const inspection = inspectImage(image);
48
+ if (inspection.ok) {
49
+ return;
50
+ }
51
+ const detail = inspection.error ? ` Detail: ${inspection.error.trim()}` : "";
52
+ throw new Error(
53
+ `seller image is not published or is not accessible: ${image}. ` +
54
+ `Publish it first with RELEASE_VERSION=<v> bash scripts/release/all.sh, or pass an existing registry.fly.io/tb-seller:<v> tag. ` +
55
+ `No Fly app was created.${detail}`
56
+ );
57
+ }
58
+
59
+ function dockerImageInspectionFromResult(result: SpawnSyncReturns<string>): DockerImageInspection {
60
+ if (result.error) {
61
+ return { ok: false, error: result.error.message };
62
+ }
63
+ if (result.status !== 0) {
64
+ return {
65
+ ok: false,
66
+ exitCode: result.status ?? undefined,
67
+ error: (result.stderr || result.stdout || "").toString()
68
+ };
69
+ }
70
+ return { ok: true };
71
+ }
72
+
73
+ export function parseFlyMachineIds(json: string, app: string): string[] {
74
+ let parsed: unknown;
75
+ try {
76
+ parsed = JSON.parse(json || "[]");
77
+ } catch {
78
+ throw new Error(`fly machines list returned invalid JSON for ${app}`);
79
+ }
80
+
81
+ if (!Array.isArray(parsed)) {
82
+ throw new Error(`fly machines list returned an unexpected shape for ${app}`);
83
+ }
84
+
85
+ const ids = parsed
86
+ .map((item) => {
87
+ if (item && typeof item === "object" && "id" in item) {
88
+ const id = (item as { id?: unknown }).id;
89
+ return typeof id === "string" && id.length > 0 ? id : undefined;
90
+ }
91
+ return undefined;
92
+ })
93
+ .filter((id): id is string => Boolean(id));
94
+
95
+ if (ids.length === 0) {
96
+ throw new Error(`fly app ${app} has no machines to update`);
97
+ }
98
+
99
+ return ids;
100
+ }
101
+
19
102
  /**
20
103
  * `FlyProvider.createSeller` 的输入。
21
104
  * 字段未提供时回退到 `SellerProviderConfig` 的默认值。
@@ -31,6 +114,7 @@ export interface SellerCreateOptions {
31
114
  volumeSizeGb?: number;
32
115
  volumeId?: string;
33
116
  volumeSnapshotRetentionDays?: number;
117
+ initialConfigPath?: string;
34
118
  dryRun?: boolean;
35
119
  }
36
120
 
@@ -40,9 +124,17 @@ export interface SellerCreateOptions {
40
124
  */
41
125
  export class FlyProvider {
42
126
  private providerConfig?: SellerProviderConfig;
127
+ private readonly runtime: Required<FlyProviderRuntime>;
43
128
 
44
- constructor(providerConfig?: SellerProviderConfig) {
129
+ constructor(providerConfig?: SellerProviderConfig, runtime?: FlyProviderRuntime) {
45
130
  this.providerConfig = providerConfig;
131
+ this.runtime = {
132
+ checkFlyctlInstalled,
133
+ execSync,
134
+ spawnSync,
135
+ imageInspector: inspectDockerImage,
136
+ ...runtime
137
+ };
46
138
  }
47
139
 
48
140
  private get flyctl(): string {
@@ -53,10 +145,10 @@ export class FlyProvider {
53
145
  * List apps on Fly.io
54
146
  */
55
147
  public listApps(): string {
56
- if (!checkFlyctlInstalled(this.flyctl)) {
148
+ if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
57
149
  throw new Error(`\`${this.flyctl}\` is not installed on your system PATH.`);
58
150
  }
59
- return execSync(`${this.flyctl} apps list`, { encoding: "utf8" });
151
+ return this.runtime.execSync(`${this.flyctl} apps list`, { encoding: "utf8" }) as string;
60
152
  }
61
153
 
62
154
  /**
@@ -83,6 +175,7 @@ export class FlyProvider {
83
175
  || 1;
84
176
  const volumeSnapshotRetentionDays = options.volumeSnapshotRetentionDays;
85
177
  const volumeId = options.volumeId;
178
+ const initialConfigPath = options.initialConfigPath;
86
179
 
87
180
  if (!targetImage) {
88
181
  throw new Error("seller create requires --image registry.fly.io/tb-seller:<v>");
@@ -100,35 +193,59 @@ export class FlyProvider {
100
193
  ` Volume: ${volumeName} (${volumeSizeGb}GB)`,
101
194
  ];
102
195
  if (flyConfig) lines.push(` Fly config: ${flyConfig}`);
196
+ if (initialConfigPath) lines.push(` Initial config secret: TOKENBUDDY_SELLER_CONFIG_B64 from ${initialConfigPath}`);
103
197
  if (volumeId) lines.push(` Volume ID: ${volumeId}`);
104
198
  if (volumeSnapshotRetentionDays !== undefined) lines.push(` Volume snapshot retention: ${volumeSnapshotRetentionDays} days`);
105
199
  return lines.join("\n");
106
200
  }
107
201
 
108
- if (!checkFlyctlInstalled(this.flyctl)) {
202
+ if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
109
203
  throw new Error(`\`${this.flyctl}\` is not installed on PATH.`);
110
204
  }
111
205
 
112
206
  if (!operatorSecret) {
113
207
  throw new Error("operator_secret is required. Provide --operator-secret or configure seller_providers.fly.operator_secret");
114
208
  }
209
+ requireReadableFile(flyConfig, "Fly config");
210
+ if (initialConfigPath) {
211
+ requireReadableFile(initialConfigPath, "Initial seller config");
212
+ }
213
+ requirePublishedDockerImage(targetImage, this.runtime.imageInspector);
115
214
 
116
215
  console.log(`[Fly.io] Creating app ${appName}...`);
117
- execSync(`${this.flyctl} apps create ${appName} --machines`, { stdio: "inherit" });
216
+ this.runtime.execSync(`${this.flyctl} apps create ${appName} --machines`, { stdio: "inherit" });
118
217
 
119
218
  console.log(`[Fly.io] Setting secrets...`);
120
- execSync(
121
- `${this.flyctl} secrets set ALLOW_MOCK=false OPERATOR_SECRET=${operatorSecret} --app ${appName}`,
122
- { stdio: "inherit" }
123
- );
219
+ this.importCreateSecrets(appName, operatorSecret, initialConfigPath);
124
220
 
125
221
  console.log(`[Fly.io] Deploying image ${targetImage}...`);
126
- const deployCmd = `${this.flyctl} deploy -c ${flyConfig} --image ${targetImage} --region ${targetRegion} --app ${appName} --now`;
127
- execSync(deployCmd, { stdio: "inherit" });
222
+ const deployCmd = `${this.flyctl} deploy -c ${flyConfig} --image ${targetImage} --primary-region ${targetRegion} --app ${appName} --now`;
223
+ this.runtime.execSync(deployCmd, { stdio: "inherit" });
128
224
 
129
225
  return `Successfully deployed ${appName} on Fly.io`;
130
226
  }
131
227
 
228
+ private importCreateSecrets(appName: string, operatorSecret: string, initialConfigPath: string | undefined): void {
229
+ const lines = [
230
+ "ALLOW_MOCK=false",
231
+ `OPERATOR_SECRET=${operatorSecret}`
232
+ ];
233
+ if (initialConfigPath) {
234
+ const configContent = fs.readFileSync(initialConfigPath, "utf8");
235
+ lines.push(`TOKENBUDDY_SELLER_CONFIG_B64=${Buffer.from(configContent, "utf8").toString("base64")}`);
236
+ }
237
+ const result = this.runtime.spawnSync(this.flyctl, ["secrets", "import", "--stage", "--app", appName], {
238
+ input: `${lines.join("\n")}\n`,
239
+ stdio: ["pipe", "inherit", "inherit"]
240
+ });
241
+ if (result.error) {
242
+ throw result.error;
243
+ }
244
+ if (result.status !== 0) {
245
+ throw new Error(`flyctl secrets import failed with exit code ${result.status}`);
246
+ }
247
+ }
248
+
132
249
  /**
133
250
  * Destroy a seller app on Fly.io.
134
251
  * @param nameOrApp Either a bare name (e.g. "86d81e") or a full app name (e.g. "tbs-86d81e").
@@ -141,12 +258,12 @@ export class FlyProvider {
141
258
  return `[DRY-RUN] Will destroy fly app: ${appName}`;
142
259
  }
143
260
 
144
- if (!checkFlyctlInstalled(this.flyctl)) {
261
+ if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
145
262
  throw new Error(`\`${this.flyctl}\` is not installed.`);
146
263
  }
147
264
 
148
265
  console.log(`[Fly.io] Destroying app ${appName}...`);
149
- execSync(`${this.flyctl} apps destroy ${appName} --yes`, { stdio: "inherit" });
266
+ this.runtime.execSync(`${this.flyctl} apps destroy ${appName} --yes`, { stdio: "inherit" });
150
267
 
151
268
  return `Successfully destroyed ${appName} on Fly.io`;
152
269
  }
@@ -156,14 +273,17 @@ export class FlyProvider {
156
273
  */
157
274
  public statusApp(name: string): string {
158
275
  const appName = name.includes("-") ? name : `tb-seller-${name}`;
159
- if (!checkFlyctlInstalled(this.flyctl)) {
276
+ if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
160
277
  throw new Error(`\`${this.flyctl}\` is not installed.`);
161
278
  }
162
- return execSync(`${this.flyctl} status --app ${appName}`, { encoding: "utf8" });
279
+ return this.runtime.execSync(`${this.flyctl} status --app ${appName}`, { encoding: "utf8" }) as string;
163
280
  }
164
281
 
165
282
  /**
166
- * Redeploy an existing seller app on Fly.io.
283
+ * Update an existing seller app's Machines to a new image.
284
+ *
285
+ * This intentionally does not call `fly deploy --config`: existing seller
286
+ * deploys must not reconcile volumes, mounts, or service configuration.
167
287
  */
168
288
  public deploySeller(options: {
169
289
  app: string;
@@ -171,32 +291,46 @@ export class FlyProvider {
171
291
  image?: string;
172
292
  dryRun?: boolean;
173
293
  }): string {
174
- const { app, config, image, dryRun } = options;
175
- const flyConfig = config;
294
+ const { app, image, dryRun } = options;
176
295
  const targetImage = image;
177
296
 
178
- if (!flyConfig) {
179
- throw new Error("seller deploy requires --fly-config deploy/fly.io/fly.tb-seller.toml");
180
- }
181
297
  if (!targetImage) {
182
298
  throw new Error("seller deploy requires --image registry.fly.io/tb-seller:<v>");
183
299
  }
184
300
 
185
301
  if (dryRun) {
186
- const lines = [`[DRY-RUN] Will redeploy app: ${app}`];
302
+ const lines = [`[DRY-RUN] Will update existing app machines: ${app}`];
187
303
  lines.push(` Image: ${targetImage}`);
188
- lines.push(` Config: ${flyConfig}`);
304
+ lines.push(" Config: unchanged");
305
+ lines.push(" Volumes: unchanged");
189
306
  return lines.join("\n");
190
307
  }
191
308
 
192
- if (!checkFlyctlInstalled(this.flyctl)) {
309
+ if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
193
310
  throw new Error(`\`${this.flyctl}\` is not installed.`);
194
311
  }
195
312
 
196
- const cmd = `${this.flyctl} deploy --app ${app} --now --yes --config ${flyConfig} --image ${targetImage}`;
313
+ const machinesJson = this.runtime.execSync(`${this.flyctl} machines list --app ${app} --json`, { encoding: "utf8" }) as string;
314
+ const machineIds = parseFlyMachineIds(machinesJson, app);
197
315
 
198
- console.log(`[Fly.io] Redeploying ${app}...`);
199
- execSync(cmd, { stdio: "inherit" });
200
- return `Successfully redeployed ${app}`;
316
+ console.log(`[Fly.io] Updating ${app} image on ${machineIds.length} machine(s)...`);
317
+ for (const machineId of machineIds) {
318
+ this.runtime.execSync(
319
+ `${this.flyctl} machine update ${machineId} --app ${app} --image ${targetImage} --yes`,
320
+ { stdio: "inherit" }
321
+ );
322
+ }
323
+ return `Successfully updated ${app} image`;
324
+ }
325
+ }
326
+
327
+ function requireReadableFile(filePath: string, label: string): void {
328
+ if (!fs.existsSync(filePath)) {
329
+ throw new Error(`${label} file does not exist: ${filePath}`);
330
+ }
331
+ const stat = fs.statSync(filePath);
332
+ if (!stat.isFile()) {
333
+ throw new Error(`${label} path is not a file: ${filePath}`);
201
334
  }
335
+ fs.accessSync(filePath, fs.constants.R_OK);
202
336
  }