@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/bin/tb-admin.js +3 -0
- package/dist/src/bootstrap-registry.d.ts +25 -0
- package/dist/src/bootstrap-registry.d.ts.map +1 -0
- package/dist/src/bootstrap-registry.js +40 -0
- package/dist/src/bootstrap-registry.js.map +1 -0
- package/dist/src/cli.d.ts +4 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +712 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/client.d.ts +11 -0
- package/dist/src/client.d.ts.map +1 -0
- package/dist/src/client.js +47 -0
- package/dist/src/client.js.map +1 -0
- package/dist/src/config.d.ts +41 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +123 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +8 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/server-cmd.d.ts +32 -0
- package/dist/src/server-cmd.d.ts.map +1 -0
- package/dist/src/server-cmd.js +103 -0
- package/dist/src/server-cmd.js.map +1 -0
- package/package.json +23 -0
- package/src/bootstrap-registry.ts +68 -0
- package/src/cli.ts +780 -0
- package/src/client.ts +54 -0
- package/src/config.ts +159 -0
- package/src/index.ts +8 -0
- package/src/server-cmd.ts +132 -0
- package/tests/admin.test.ts +109 -0
- package/tsconfig.json +8 -0
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,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
|
+
});
|