@tokenbuddy/tb-admin 1.0.0

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/src/client.ts ADDED
@@ -0,0 +1,54 @@
1
+ export class AdminClient {
2
+ private baseUrl: string;
3
+ private token: string;
4
+
5
+ constructor(baseUrl: string, token: string) {
6
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
7
+ this.token = token;
8
+ }
9
+
10
+ private async request(path: string, method: string, body?: any): Promise<any> {
11
+ const url = `${this.baseUrl}${path}`;
12
+ const headers: { [key: string]: string } = {
13
+ "Content-Type": "application/json",
14
+ "Authorization": `Bearer ${this.token}`
15
+ };
16
+
17
+ const options: RequestInit = {
18
+ method,
19
+ headers
20
+ };
21
+
22
+ if (body) {
23
+ options.body = JSON.stringify(body);
24
+ }
25
+
26
+ try {
27
+ const response = await fetch(url, options);
28
+ if (!response.ok) {
29
+ const errorText = await response.text();
30
+ throw new Error(`HTTP Error ${response.status}: ${errorText || response.statusText}`);
31
+ }
32
+ const text = await response.text();
33
+ return text ? JSON.parse(text) : {};
34
+ } catch (err: any) {
35
+ throw new Error(`Connection failed: ${err.message}`);
36
+ }
37
+ }
38
+
39
+ public async get(path: string): Promise<any> {
40
+ return this.request(path, "GET");
41
+ }
42
+
43
+ public async put(path: string, body?: any): Promise<any> {
44
+ return this.request(path, "PUT", body);
45
+ }
46
+
47
+ public async post(path: string, body?: any): Promise<any> {
48
+ return this.request(path, "POST", body);
49
+ }
50
+
51
+ public async delete(path: string): Promise<any> {
52
+ return this.request(path, "DELETE");
53
+ }
54
+ }
package/src/config.ts ADDED
@@ -0,0 +1,159 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ import TOML from "@iarna/toml";
5
+
6
+ export interface AdminProfile {
7
+ url: string;
8
+ token: string;
9
+ }
10
+
11
+ export interface SellerProviderConfig {
12
+ token?: string;
13
+ flyctl_path?: string;
14
+ default_app?: string;
15
+ default_region?: string;
16
+ default_image?: string;
17
+ default_config?: string;
18
+ operator_secret?: string;
19
+ volume_name?: string;
20
+ volume_size_gb?: number;
21
+ volume_snapshot_retention_days?: number;
22
+ }
23
+
24
+ export interface AdminConfig {
25
+ default_profile?: string;
26
+ profiles: { [name: string]: AdminProfile };
27
+ seller_providers?: { [name: string]: SellerProviderConfig };
28
+ }
29
+
30
+ export function getDefaultConfigPath(): string {
31
+ const home = os.homedir();
32
+ return path.join(home, ".config", "tokenbuddy", "admin.toml");
33
+ }
34
+
35
+ export class ConfigManager {
36
+ private configPath: string;
37
+
38
+ constructor(customPath?: string) {
39
+ this.configPath = customPath || getDefaultConfigPath();
40
+ }
41
+
42
+ public getConfigPath(): string {
43
+ return this.configPath;
44
+ }
45
+
46
+ public load(): AdminConfig {
47
+ if (!fs.existsSync(this.configPath)) {
48
+ return { profiles: {} };
49
+ }
50
+ try {
51
+ const content = fs.readFileSync(this.configPath, "utf8");
52
+
53
+ // Support legacy JSON format for backward compatibility
54
+ if (this.configPath.endsWith(".json")) {
55
+ return JSON.parse(content) as AdminConfig;
56
+ }
57
+
58
+ const parsed = TOML.parse(content) as any;
59
+ // TOML nested tables [profiles.xxx] are parsed as { profiles: { xxx: { ... } } }
60
+ return {
61
+ default_profile: parsed.default_profile,
62
+ profiles: parsed.profiles || {},
63
+ seller_providers: parsed.seller_providers || undefined
64
+ };
65
+ } catch {
66
+ return { profiles: {} };
67
+ }
68
+ }
69
+
70
+ public save(config: AdminConfig): void {
71
+ const parent = path.dirname(this.configPath);
72
+ if (!fs.existsSync(parent)) {
73
+ fs.mkdirSync(parent, { recursive: true });
74
+ }
75
+
76
+ if (this.configPath.endsWith(".json")) {
77
+ // Legacy JSON format
78
+ fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), "utf8");
79
+ } else {
80
+ // TOML format (default)
81
+ const tomlObj: any = {};
82
+ if (config.default_profile) {
83
+ tomlObj.default_profile = config.default_profile;
84
+ }
85
+ if (config.profiles) {
86
+ tomlObj.profiles = config.profiles;
87
+ }
88
+ if (config.seller_providers) {
89
+ tomlObj.seller_providers = config.seller_providers;
90
+ }
91
+ const content = TOML.stringify(tomlObj);
92
+ fs.writeFileSync(this.configPath, content, "utf8");
93
+
94
+ // chmod 0600 for security (contains tokens) - match Rust behavior
95
+ try {
96
+ fs.chmodSync(this.configPath, 0o600);
97
+ } catch {
98
+ // Ignore on platforms that don't support chmod
99
+ }
100
+ }
101
+ }
102
+
103
+ public getProfile(name?: string): AdminProfile | undefined {
104
+ const config = this.load();
105
+ const targetName = name || config.default_profile;
106
+ if (!targetName) return undefined;
107
+ return config.profiles[targetName];
108
+ }
109
+
110
+ public setProfile(name: string, profile: AdminProfile): void {
111
+ const config = this.load();
112
+ config.profiles[name] = profile;
113
+ if (!config.default_profile) {
114
+ config.default_profile = name;
115
+ }
116
+ this.save(config);
117
+ }
118
+
119
+ public removeProfile(name: string): void {
120
+ const config = this.load();
121
+ if (!config.profiles[name]) {
122
+ throw new Error(`Profile \`${name}\` does not exist.`);
123
+ }
124
+ delete config.profiles[name];
125
+ if (config.default_profile === name) {
126
+ const remaining = Object.keys(config.profiles);
127
+ config.default_profile = remaining.length > 0 ? remaining[0] : undefined;
128
+ }
129
+ this.save(config);
130
+ }
131
+
132
+ public useProfile(name: string): void {
133
+ const config = this.load();
134
+ if (!config.profiles[name]) {
135
+ throw new Error(`Profile \`${name}\` does not exist.`);
136
+ }
137
+ config.default_profile = name;
138
+ this.save(config);
139
+ }
140
+
141
+ public listProfiles(): string[] {
142
+ const config = this.load();
143
+ return Object.keys(config.profiles);
144
+ }
145
+
146
+ public getSellerProvider(name: string = "fly"): SellerProviderConfig | undefined {
147
+ const config = this.load();
148
+ return config.seller_providers?.[name];
149
+ }
150
+
151
+ public setSellerProvider(name: string, provider: SellerProviderConfig): void {
152
+ const config = this.load();
153
+ if (!config.seller_providers) {
154
+ config.seller_providers = {};
155
+ }
156
+ config.seller_providers[name] = provider;
157
+ this.save(config);
158
+ }
159
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { ConfigManager } from "./config.js";
2
+ import { buildAdminCli } from "./cli.js";
3
+
4
+ export function run() {
5
+ const configManager = new ConfigManager();
6
+ const program = buildAdminCli(configManager);
7
+ program.parse(process.argv);
8
+ }
@@ -0,0 +1,132 @@
1
+ import { execSync } from "child_process";
2
+ import { SellerProviderConfig } from "./config.js";
3
+
4
+ export function checkFlyctlInstalled(flyctlPath?: string): boolean {
5
+ try {
6
+ execSync(`which ${flyctlPath || "flyctl"}`, { stdio: "ignore" });
7
+ return true;
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+
13
+ export interface SellerCreateOptions {
14
+ name: string;
15
+ region?: string;
16
+ image?: string;
17
+ operatorSecret: string;
18
+ flyConfig?: string;
19
+ dryRun?: boolean;
20
+ }
21
+
22
+ export class FlyProvider {
23
+ private providerConfig?: SellerProviderConfig;
24
+
25
+ constructor(providerConfig?: SellerProviderConfig) {
26
+ this.providerConfig = providerConfig;
27
+ }
28
+
29
+ private get flyctl(): string {
30
+ return this.providerConfig?.flyctl_path || "flyctl";
31
+ }
32
+
33
+ /**
34
+ * List apps on Fly.io
35
+ */
36
+ public listApps(): string {
37
+ if (!checkFlyctlInstalled(this.flyctl)) {
38
+ throw new Error(`\`${this.flyctl}\` is not installed on your system PATH.`);
39
+ }
40
+ return execSync(`${this.flyctl} apps list`, { encoding: "utf8" });
41
+ }
42
+
43
+ /**
44
+ * Create and deploy a new seller app on Fly.io
45
+ */
46
+ public createSeller(options: SellerCreateOptions): string {
47
+ const { name, dryRun } = options;
48
+ const appName = `tb-seller-${name}`;
49
+
50
+ // Merge CLI options with provider config defaults
51
+ const targetImage = options.image
52
+ || this.providerConfig?.default_image
53
+ || "registry.fly.io/tb-seller:latest";
54
+ const targetRegion = options.region
55
+ || this.providerConfig?.default_region
56
+ || "hkg";
57
+ const operatorSecret = options.operatorSecret
58
+ || this.providerConfig?.operator_secret
59
+ || "";
60
+ const flyConfig = options.flyConfig
61
+ || this.providerConfig?.default_config;
62
+ const volumeName = this.providerConfig?.volume_name || "tb_seller_data";
63
+ const volumeSizeGb = this.providerConfig?.volume_size_gb || 1;
64
+
65
+ if (dryRun) {
66
+ const lines = [
67
+ `[DRY-RUN] Will create fly app: ${appName}`,
68
+ ` Region: ${targetRegion}`,
69
+ ` Image: ${targetImage}`,
70
+ ` Secret: OPERATOR_SECRET=***${operatorSecret.slice(-4) || "????"}`,
71
+ ` Volume: ${volumeName} (${volumeSizeGb}GB)`,
72
+ ];
73
+ if (flyConfig) lines.push(` Fly config: ${flyConfig}`);
74
+ return lines.join("\n");
75
+ }
76
+
77
+ if (!checkFlyctlInstalled(this.flyctl)) {
78
+ throw new Error(`\`${this.flyctl}\` is not installed on PATH.`);
79
+ }
80
+
81
+ if (!operatorSecret) {
82
+ throw new Error("operator_secret is required. Provide --operator-secret or configure seller_providers.fly.operator_secret");
83
+ }
84
+
85
+ console.log(`[Fly.io] Creating app ${appName}...`);
86
+ execSync(`${this.flyctl} apps create ${appName} --machines`, { stdio: "inherit" });
87
+
88
+ console.log(`[Fly.io] Setting secrets...`);
89
+ execSync(
90
+ `${this.flyctl} secrets set ALLOW_MOCK=false OPERATOR_SECRET=${operatorSecret} --app ${appName}`,
91
+ { stdio: "inherit" }
92
+ );
93
+
94
+ console.log(`[Fly.io] Deploying image ${targetImage}...`);
95
+ const deployCmd = flyConfig
96
+ ? `${this.flyctl} deploy -c ${flyConfig} --image ${targetImage} --region ${targetRegion} --app ${appName} --now`
97
+ : `${this.flyctl} deploy --image ${targetImage} --region ${targetRegion} --app ${appName} --now`;
98
+ execSync(deployCmd, { stdio: "inherit" });
99
+
100
+ return `Successfully deployed ${appName} on Fly.io`;
101
+ }
102
+
103
+ /**
104
+ * Destroy a seller app on Fly.io
105
+ */
106
+ public removeSeller(name: string, dryRun?: boolean): string {
107
+ const appName = `tb-seller-${name}`;
108
+ if (dryRun) {
109
+ return `[DRY-RUN] Will destroy fly app: ${appName}`;
110
+ }
111
+
112
+ if (!checkFlyctlInstalled(this.flyctl)) {
113
+ throw new Error(`\`${this.flyctl}\` is not installed.`);
114
+ }
115
+
116
+ console.log(`[Fly.io] Destroying app ${appName}...`);
117
+ execSync(`${this.flyctl} apps destroy ${appName} --yes`, { stdio: "inherit" });
118
+
119
+ return `Successfully destroyed ${appName} on Fly.io`;
120
+ }
121
+
122
+ /**
123
+ * Get status of a specific seller app
124
+ */
125
+ public statusApp(name: string): string {
126
+ const appName = `tb-seller-${name}`;
127
+ if (!checkFlyctlInstalled(this.flyctl)) {
128
+ throw new Error(`\`${this.flyctl}\` is not installed.`);
129
+ }
130
+ return execSync(`${this.flyctl} status --app ${appName}`, { encoding: "utf8" });
131
+ }
132
+ }
@@ -0,0 +1,109 @@
1
+ import { ConfigManager } from "../src/config.js";
2
+ import { buildAdminCli } from "../src/cli.js";
3
+ import {
4
+ validateRegistryDocument
5
+ } from "../src/bootstrap-registry.js";
6
+ import * as fs from "fs";
7
+ import * as path from "path";
8
+
9
+ const TEMP_CONF_PATH = path.resolve(__dirname, "../../data-test/admin-config.json");
10
+
11
+ describe("Admin CLI Config Profile Management Tests", () => {
12
+ beforeEach(() => {
13
+ if (fs.existsSync(TEMP_CONF_PATH)) {
14
+ try { fs.unlinkSync(TEMP_CONF_PATH); } catch (e) {}
15
+ }
16
+ });
17
+
18
+ afterAll(() => {
19
+ if (fs.existsSync(TEMP_CONF_PATH)) {
20
+ try { fs.unlinkSync(TEMP_CONF_PATH); } catch (e) {}
21
+ }
22
+ const dir = path.dirname(TEMP_CONF_PATH);
23
+ if (fs.existsSync(dir)) {
24
+ try { fs.rmdirSync(dir); } catch (e) {}
25
+ }
26
+ });
27
+
28
+ test("Load and save profile seamlessly", () => {
29
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
30
+
31
+ // Initial empty
32
+ expect(mgr.listProfiles().length).toBe(0);
33
+
34
+ // Save profile 1
35
+ mgr.setProfile("prod", { url: "http://127.0.0.1:8000", token: "secret-op" });
36
+ expect(mgr.listProfiles()).toContain("prod");
37
+
38
+ const p = mgr.getProfile("prod");
39
+ expect(p?.url).toBe("http://127.0.0.1:8000");
40
+ expect(p?.token).toBe("secret-op");
41
+
42
+ // Default profile is automatically set
43
+ const defaultP = mgr.getProfile();
44
+ expect(defaultP?.url).toBe("http://127.0.0.1:8000");
45
+ });
46
+
47
+ test("Switch default profiles", () => {
48
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
49
+ mgr.setProfile("prod", { url: "http://127.0.0.1:8000", token: "secret-op" });
50
+ mgr.setProfile("dev", { url: "http://127.0.0.1:8001", token: "dev-op" });
51
+
52
+ mgr.useProfile("dev");
53
+ const active = mgr.getProfile();
54
+ expect(active?.url).toBe("http://127.0.0.1:8001");
55
+ expect(active?.token).toBe("dev-op");
56
+
57
+ expect(() => {
58
+ mgr.useProfile("non-exist");
59
+ }).toThrow("does not exist");
60
+ });
61
+
62
+ test("bootstrap sellers commands parse without global option conflicts", () => {
63
+ const program = buildAdminCli(new ConfigManager(TEMP_CONF_PATH));
64
+ const bootstrap = program.commands.find((command) => command.name() === "bootstrap");
65
+ const sellers = bootstrap?.commands.find((command) => command.name() === "sellers");
66
+ const sellerConfig = program.commands.find((command) => command.name() === "seller-config");
67
+ const payments = program.commands.find((command) => command.name() === "payments");
68
+ const upstreams = program.commands.find((command) => command.name() === "upstreams");
69
+
70
+ expect(bootstrap).toBeDefined();
71
+ expect(sellers).toBeDefined();
72
+ expect(sellers?.commands.map((command) => command.name()).sort()).toEqual(["get", "put", "validate"]);
73
+ expect(sellers?.commands.find((command) => command.name() === "put")?.options.some((option) => option.long === "--file")).toBe(true);
74
+ expect(sellers?.commands.find((command) => command.name() === "validate")?.options.some((option) => option.long === "--file")).toBe(true);
75
+ expect(sellerConfig).toBeDefined();
76
+ expect(sellerConfig?.commands.map((command) => command.name()).sort()).toEqual(["get", "put", "validate"]);
77
+ expect(sellerConfig?.commands.find((command) => command.name() === "put")?.options.some((option) => option.long === "--file")).toBe(true);
78
+ expect(sellerConfig?.commands.find((command) => command.name() === "validate")?.options.some((option) => option.long === "--file")).toBe(true);
79
+ expect(upstreams).toBeDefined();
80
+ expect(upstreams?.commands.map((command) => command.name()).sort()).toEqual(["get", "refresh", "update"]);
81
+ expect(upstreams?.commands.find((command) => command.name() === "update")?.options.some((option) => option.long === "--upstream-url")).toBe(true);
82
+ expect(upstreams?.commands.find((command) => command.name() === "refresh")?.options.some((option) => option.long === "--auto-models")).toBe(true);
83
+ expect(payments?.commands.map((command) => command.name()).sort()).toEqual([
84
+ "clear-clawtip",
85
+ "disable-mock",
86
+ "enable-mock",
87
+ "list",
88
+ "set-clawtip"
89
+ ]);
90
+ });
91
+
92
+ test("bootstrap registry validation accepts demo config and rejects unsafe variants", () => {
93
+ const document = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../../config/demo-sellers.json"), "utf8"));
94
+
95
+ expect(() => validateRegistryDocument(document)).not.toThrow();
96
+
97
+ const httpUrl = structuredClone(document);
98
+ httpUrl.sellers[0].url = "http://seller.example.com";
99
+ expect(() => validateRegistryDocument(httpUrl)).toThrow("url must be https");
100
+
101
+ const duplicate = structuredClone(document);
102
+ duplicate.sellers[1].id = duplicate.sellers[0].id;
103
+ expect(() => validateRegistryDocument(duplicate)).toThrow("duplicate seller id");
104
+
105
+ const badDefault = structuredClone(document);
106
+ badDefault.defaultSeller = "missing";
107
+ expect(() => validateRegistryDocument(badDefault)).toThrow("defaultSeller `missing`");
108
+ });
109
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./"
6
+ },
7
+ "include": ["src/**/*"]
8
+ }