solforge 0.1.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.
@@ -0,0 +1,259 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import { existsSync, readFileSync } from "fs";
4
+ import { join } from "path";
5
+ import { input, select } from "@inquirer/prompts";
6
+ import { runCommand } from "../utils/shell";
7
+ import { Keypair } from "@solana/web3.js";
8
+
9
+ interface TokenBalance {
10
+ mint: string;
11
+ symbol: string;
12
+ address: string;
13
+ balance: string;
14
+ decimals: number;
15
+ }
16
+
17
+ interface TokenConfig {
18
+ symbol: string;
19
+ mainnetMint: string;
20
+ mintAmount: number;
21
+ }
22
+
23
+ export const transferCommand = new Command()
24
+ .name("transfer")
25
+ .description(
26
+ "Interactively transfer tokens from mint authority to any address"
27
+ )
28
+ .option("--rpc-url <url>", "RPC URL to use", "http://127.0.0.1:8899")
29
+ .action(async (options) => {
30
+ try {
31
+ console.log(chalk.blue("🔄 Interactive Token Transfer"));
32
+ console.log(
33
+ chalk.gray("Send tokens from mint authority to any address\n")
34
+ );
35
+
36
+ // Check if solforge data exists
37
+ const workDir = ".solforge";
38
+ if (!existsSync(workDir)) {
39
+ console.error(
40
+ chalk.red("❌ No solforge data found. Run 'solforge start' first.")
41
+ );
42
+ process.exit(1);
43
+ }
44
+
45
+ // Load available tokens and their balances
46
+ const tokens = await loadAvailableTokens(workDir, options.rpcUrl);
47
+
48
+ if (tokens.length === 0) {
49
+ console.error(
50
+ chalk.red(
51
+ "❌ No tokens found. Run 'solforge start' first to clone tokens."
52
+ )
53
+ );
54
+ process.exit(1);
55
+ }
56
+
57
+ // Display available tokens
58
+ console.log(chalk.cyan("📋 Available Tokens:"));
59
+ tokens.forEach((token, index) => {
60
+ console.log(
61
+ chalk.gray(
62
+ ` ${index + 1}. ${token.symbol} (${token.mint}) - Balance: ${
63
+ token.balance
64
+ }`
65
+ )
66
+ );
67
+ });
68
+ console.log();
69
+
70
+ // Select token
71
+ const selectedToken = await select({
72
+ message: "Select a token to transfer:",
73
+ choices: tokens.map((token, index) => ({
74
+ name: `${token.symbol} - Balance: ${token.balance}`,
75
+ value: token,
76
+ })),
77
+ });
78
+
79
+ // Get recipient address
80
+ const recipientAddress = await input({
81
+ message: "Enter recipient address (wallet address or PDA):",
82
+ validate: (value: string) => {
83
+ if (!value.trim()) {
84
+ return "Please enter a valid address";
85
+ }
86
+ // Basic length check for Solana addresses
87
+ if (value.trim().length < 32 || value.trim().length > 44) {
88
+ return "Please enter a valid Solana address (32-44 characters)";
89
+ }
90
+ return true;
91
+ },
92
+ });
93
+
94
+ // Get amount to transfer
95
+ const maxAmount = parseFloat(selectedToken.balance);
96
+ const amount = await input({
97
+ message: `Enter amount to transfer (max: ${selectedToken.balance}):`,
98
+ validate: (value: string) => {
99
+ const num = parseFloat(value);
100
+ if (isNaN(num) || num <= 0) {
101
+ return "Please enter a valid positive number";
102
+ }
103
+ if (num > maxAmount) {
104
+ return `Amount cannot exceed available balance: ${selectedToken.balance}`;
105
+ }
106
+ return true;
107
+ },
108
+ });
109
+
110
+ // Use amount as-is (spl-token expects UI amount, not base units)
111
+ const transferAmount = amount;
112
+
113
+ // Confirm transfer
114
+ const confirm = await input({
115
+ message: `Confirm transfer of ${amount} ${selectedToken.symbol} to ${recipientAddress}? (y/N):`,
116
+ default: "N",
117
+ });
118
+
119
+ if (confirm.toLowerCase() !== "y" && confirm.toLowerCase() !== "yes") {
120
+ console.log(chalk.yellow("Transfer cancelled."));
121
+ process.exit(0);
122
+ }
123
+
124
+ console.log(chalk.blue("🚀 Starting transfer..."));
125
+
126
+ // Execute transfer
127
+ await executeTransfer(
128
+ selectedToken,
129
+ recipientAddress,
130
+ transferAmount.toString(),
131
+ options.rpcUrl,
132
+ workDir
133
+ );
134
+
135
+ console.log(
136
+ chalk.green(
137
+ `✅ Successfully transferred ${amount} ${selectedToken.symbol} to ${recipientAddress}`
138
+ )
139
+ );
140
+ } catch (error) {
141
+ console.error(chalk.red(`❌ Transfer failed: ${error}`));
142
+ process.exit(1);
143
+ }
144
+ });
145
+
146
+ async function loadAvailableTokens(
147
+ workDir: string,
148
+ rpcUrl: string
149
+ ): Promise<TokenBalance[]> {
150
+ const tokens: TokenBalance[] = [];
151
+
152
+ try {
153
+ // Load shared mint authority secret key and generate keypair
154
+ const sharedMintAuthorityPath = join(workDir, "shared-mint-authority.json");
155
+ if (!existsSync(sharedMintAuthorityPath)) {
156
+ throw new Error("Shared mint authority not found");
157
+ }
158
+
159
+ const secretKeyArray = JSON.parse(
160
+ readFileSync(sharedMintAuthorityPath, "utf8")
161
+ );
162
+ const mintAuthorityKeypair = Keypair.fromSecretKey(
163
+ new Uint8Array(secretKeyArray)
164
+ );
165
+ const mintAuthorityAddress = mintAuthorityKeypair.publicKey.toBase58();
166
+
167
+ // Load token config from sf.config.json
168
+ const configPath = "sf.config.json";
169
+ if (!existsSync(configPath)) {
170
+ throw new Error("sf.config.json not found in current directory");
171
+ }
172
+
173
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
174
+ const tokenConfigs: TokenConfig[] = config.tokens || [];
175
+
176
+ // Get token account balances
177
+ const accountsResult = await runCommand(
178
+ "spl-token",
179
+ [
180
+ "accounts",
181
+ "--owner",
182
+ mintAuthorityAddress,
183
+ "--url",
184
+ rpcUrl,
185
+ "--output",
186
+ "json",
187
+ ],
188
+ { silent: true }
189
+ );
190
+
191
+ if (!accountsResult.success) {
192
+ throw new Error(
193
+ `Failed to fetch token accounts: ${accountsResult.stderr}`
194
+ );
195
+ }
196
+
197
+ const accountsData = JSON.parse(accountsResult.stdout);
198
+
199
+ // Match accounts with token config
200
+ for (const account of accountsData.accounts || []) {
201
+ const tokenInfo = tokenConfigs.find(
202
+ (token: TokenConfig) => token.mainnetMint === account.mint
203
+ );
204
+ if (tokenInfo) {
205
+ tokens.push({
206
+ mint: account.mint,
207
+ symbol: tokenInfo.symbol,
208
+ address: account.address,
209
+ balance: account.tokenAmount.uiAmountString,
210
+ decimals: account.tokenAmount.decimals,
211
+ });
212
+ }
213
+ }
214
+
215
+ return tokens;
216
+ } catch (error) {
217
+ throw new Error(`Failed to load tokens: ${error}`);
218
+ }
219
+ }
220
+
221
+ async function executeTransfer(
222
+ token: TokenBalance,
223
+ recipientAddress: string,
224
+ amount: string,
225
+ rpcUrl: string,
226
+ workDir: string
227
+ ): Promise<void> {
228
+ // Load mint authority keypair path
229
+ const sharedMintAuthorityPath = join(workDir, "shared-mint-authority.json");
230
+
231
+ // Transfer tokens directly to wallet address
232
+ // The --fund-recipient flag will automatically create the associated token account if needed
233
+ console.log(chalk.gray("💸 Transferring tokens..."));
234
+
235
+ const transferResult = await runCommand(
236
+ "spl-token",
237
+ [
238
+ "transfer",
239
+ token.mint,
240
+ amount,
241
+ recipientAddress,
242
+ "--from",
243
+ token.address,
244
+ "--owner",
245
+ sharedMintAuthorityPath,
246
+ "--fee-payer",
247
+ sharedMintAuthorityPath,
248
+ "--fund-recipient",
249
+ "--allow-unfunded-recipient",
250
+ "--url",
251
+ rpcUrl,
252
+ ],
253
+ { silent: false }
254
+ );
255
+
256
+ if (!transferResult.success) {
257
+ throw new Error(`Transfer failed: ${transferResult.stderr}`);
258
+ }
259
+ }
@@ -0,0 +1,157 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { ConfigSchema } from "../types/config.js";
4
+ import type { Config, ValidationResult } from "../types/config.js";
5
+
6
+ export class ConfigManager {
7
+ private config: Config | null = null;
8
+ private configPath: string | null = null;
9
+
10
+ /**
11
+ * Load configuration from a file path
12
+ */
13
+ async load(configPath: string): Promise<Config> {
14
+ try {
15
+ const fullPath = resolve(configPath);
16
+
17
+ if (!existsSync(fullPath)) {
18
+ throw new Error(`Configuration file not found: ${fullPath}`);
19
+ }
20
+
21
+ const configContent = readFileSync(fullPath, "utf-8");
22
+ const rawConfig = JSON.parse(configContent);
23
+
24
+ // Validate and parse with Zod
25
+ const result = ConfigSchema.safeParse(rawConfig);
26
+
27
+ if (!result.success) {
28
+ const errors = result.error.issues.map((issue) => ({
29
+ path: issue.path.join("."),
30
+ message: issue.message,
31
+ }));
32
+ throw new Error(
33
+ `Configuration validation failed:\n${errors
34
+ .map((e) => ` - ${e.path}: ${e.message}`)
35
+ .join("\n")}`
36
+ );
37
+ }
38
+
39
+ this.config = result.data;
40
+ this.configPath = fullPath;
41
+
42
+ return this.config;
43
+ } catch (error) {
44
+ if (error instanceof SyntaxError) {
45
+ throw new Error(`Invalid JSON in configuration file: ${error.message}`);
46
+ }
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Save current configuration to file
53
+ */
54
+ async save(configPath?: string): Promise<void> {
55
+ if (!this.config) {
56
+ throw new Error("No configuration loaded");
57
+ }
58
+
59
+ const targetPath = configPath || this.configPath;
60
+ if (!targetPath) {
61
+ throw new Error("No configuration path specified");
62
+ }
63
+
64
+ try {
65
+ const configContent = JSON.stringify(this.config, null, 2);
66
+ writeFileSync(targetPath, configContent, "utf-8");
67
+ this.configPath = targetPath;
68
+ } catch (error) {
69
+ throw new Error(
70
+ `Failed to save configuration: ${
71
+ error instanceof Error ? error.message : String(error)
72
+ }`
73
+ );
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Validate a configuration object
79
+ */
80
+ validate(config: any): ValidationResult {
81
+ const result = ConfigSchema.safeParse(config);
82
+
83
+ if (result.success) {
84
+ return { valid: true, errors: [] };
85
+ }
86
+
87
+ const errors = result.error.issues.map((issue) => ({
88
+ path: issue.path.join("."),
89
+ message: issue.message,
90
+ }));
91
+
92
+ return { valid: false, errors };
93
+ }
94
+
95
+ /**
96
+ * Create a default configuration
97
+ */
98
+ createDefault(): Config {
99
+ const defaultConfig = ConfigSchema.parse({});
100
+ this.config = defaultConfig;
101
+ return defaultConfig;
102
+ }
103
+
104
+ /**
105
+ * Get current configuration
106
+ */
107
+ getConfig(): Config {
108
+ if (!this.config) {
109
+ throw new Error("No configuration loaded. Call load() first.");
110
+ }
111
+ return this.config;
112
+ }
113
+
114
+ /**
115
+ * Update configuration
116
+ */
117
+ updateConfig(updates: Partial<Config>): Config {
118
+ if (!this.config) {
119
+ throw new Error("No configuration loaded. Call load() first.");
120
+ }
121
+
122
+ const updated = { ...this.config, ...updates };
123
+ const result = ConfigSchema.safeParse(updated);
124
+
125
+ if (!result.success) {
126
+ const errors = result.error.issues.map((issue) => ({
127
+ path: issue.path.join("."),
128
+ message: issue.message,
129
+ }));
130
+ throw new Error(
131
+ `Configuration update validation failed:\n${errors
132
+ .map((e) => ` - ${e.path}: ${e.message}`)
133
+ .join("\n")}`
134
+ );
135
+ }
136
+
137
+ this.config = result.data;
138
+ return this.config;
139
+ }
140
+
141
+ /**
142
+ * Get configuration file path
143
+ */
144
+ getConfigPath(): string | null {
145
+ return this.configPath;
146
+ }
147
+
148
+ /**
149
+ * Check if configuration is loaded
150
+ */
151
+ isLoaded(): boolean {
152
+ return this.config !== null;
153
+ }
154
+ }
155
+
156
+ // Singleton instance
157
+ export const configManager = new ConfigManager();
package/src/index.ts ADDED
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { Command } from "commander";
4
+ import chalk from "chalk";
5
+ import { existsSync } from "fs";
6
+ import { resolve } from "path";
7
+ import { initCommand } from "./commands/init.js";
8
+ import { statusCommand } from "./commands/status.js";
9
+ import { startCommand } from "./commands/start.js";
10
+ import { transferCommand } from "./commands/transfer.js";
11
+ import { listCommand } from "./commands/list.js";
12
+ import { stopCommand, killCommand } from "./commands/stop.js";
13
+ import { addProgramCommand } from "./commands/add-program.js";
14
+
15
+ const program = new Command();
16
+
17
+ program
18
+ .name("solforge")
19
+ .description("Solana localnet orchestration tool")
20
+ .version("0.1.0");
21
+
22
+ // Check for sf.config.json in current directory
23
+ function findConfig(): string | null {
24
+ const configPath = resolve(process.cwd(), "sf.config.json");
25
+ return existsSync(configPath) ? configPath : null;
26
+ }
27
+
28
+ program
29
+ .command("init")
30
+ .description("Initialize a new sf.config.json in current directory")
31
+ .action(async () => {
32
+ console.log(chalk.blue("🚀 Initializing SolForge configuration..."));
33
+ await initCommand();
34
+ });
35
+
36
+ program
37
+ .command("start")
38
+ .description("Start localnet with current sf.config.json")
39
+ .option("--debug", "Enable debug logging to see commands and detailed output")
40
+ .action(async (options) => {
41
+ const configPath = findConfig();
42
+ if (!configPath) {
43
+ console.error(
44
+ chalk.red("❌ No sf.config.json found in current directory")
45
+ );
46
+ console.log(chalk.yellow("💡 Run `solforge init` to create one"));
47
+ process.exit(1);
48
+ }
49
+
50
+ await startCommand(options.debug || false);
51
+ });
52
+
53
+ program
54
+ .command("list")
55
+ .description("List all running validators")
56
+ .action(async () => {
57
+ await listCommand();
58
+ });
59
+
60
+ program
61
+ .command("stop")
62
+ .description("Stop running validator(s)")
63
+ .argument("[validator-id]", "ID of validator to stop")
64
+ .option("--all", "Stop all running validators")
65
+ .option("--kill", "Force kill the validator (SIGKILL instead of SIGTERM)")
66
+ .action(async (validatorId, options) => {
67
+ await stopCommand(validatorId, options);
68
+ });
69
+
70
+ program
71
+ .command("kill")
72
+ .description("Force kill running validator(s)")
73
+ .argument("[validator-id]", "ID of validator to kill")
74
+ .option("--all", "Kill all running validators")
75
+ .action(async (validatorId, options) => {
76
+ await killCommand(validatorId, options);
77
+ });
78
+
79
+ program
80
+ .command("add-program")
81
+ .description("Add a program to sf.config.json")
82
+
83
+ .option("--program-id <address>", "Mainnet program ID to clone and deploy")
84
+ .option("--name <name>", "Friendly name for the program")
85
+ .option("--no-interactive", "Run in non-interactive mode")
86
+ .action(async (options) => {
87
+ await addProgramCommand(options);
88
+ });
89
+
90
+ program
91
+ .command("status")
92
+ .description("Show localnet status")
93
+ .action(async () => {
94
+ await statusCommand();
95
+ });
96
+
97
+ program.addCommand(transferCommand);
98
+
99
+ program
100
+ .command("reset")
101
+ .description("Reset localnet ledger")
102
+ .action(async () => {
103
+ console.log(chalk.blue("🔄 Resetting localnet..."));
104
+ // TODO: Implement reset
105
+ });
106
+
107
+ program.parse();
@@ -0,0 +1,177 @@
1
+ import { processRegistry } from "./process-registry.js";
2
+
3
+ export interface PortAllocation {
4
+ rpcPort: number;
5
+ faucetPort: number;
6
+ }
7
+
8
+ export class PortManager {
9
+ private readonly defaultRpcPort = 8899;
10
+ private readonly defaultFaucetPort = 9900;
11
+ private readonly portRangeStart = 8000;
12
+ private readonly portRangeEnd = 9999;
13
+
14
+ /**
15
+ * Get the next available port pair (RPC + Faucet)
16
+ */
17
+ async getAvailablePorts(preferredRpcPort?: number): Promise<PortAllocation> {
18
+ const usedPorts = this.getUsedPorts();
19
+
20
+ // If preferred port is specified and available, use it
21
+ if (preferredRpcPort && !this.isPortUsed(preferredRpcPort, usedPorts)) {
22
+ const faucetPort = this.findAvailableFaucetPort(
23
+ preferredRpcPort,
24
+ usedPorts
25
+ );
26
+ if (faucetPort) {
27
+ return { rpcPort: preferredRpcPort, faucetPort };
28
+ }
29
+ }
30
+
31
+ // Otherwise, find the next available ports
32
+ return this.findNextAvailablePorts(usedPorts);
33
+ }
34
+
35
+ /**
36
+ * Check if a specific port is available
37
+ */
38
+ async isPortAvailable(port: number): Promise<boolean> {
39
+ const usedPorts = this.getUsedPorts();
40
+ return (
41
+ !this.isPortUsed(port, usedPorts) &&
42
+ (await this.checkPortActuallyFree(port))
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Get all currently used ports from running validators
48
+ */
49
+ private getUsedPorts(): Set<number> {
50
+ const validators = processRegistry.getRunning();
51
+ const usedPorts = new Set<number>();
52
+
53
+ validators.forEach((validator) => {
54
+ usedPorts.add(validator.rpcPort);
55
+ usedPorts.add(validator.faucetPort);
56
+ });
57
+
58
+ return usedPorts;
59
+ }
60
+
61
+ /**
62
+ * Check if a port is in the used ports set
63
+ */
64
+ private isPortUsed(port: number, usedPorts: Set<number>): boolean {
65
+ return usedPorts.has(port);
66
+ }
67
+
68
+ /**
69
+ * Find an available faucet port for a given RPC port
70
+ */
71
+ private findAvailableFaucetPort(
72
+ rpcPort: number,
73
+ usedPorts: Set<number>
74
+ ): number | null {
75
+ // Try default offset first (faucet = rpc + 1001)
76
+ let faucetPort = rpcPort + 1001;
77
+ if (
78
+ !this.isPortUsed(faucetPort, usedPorts) &&
79
+ this.isPortInRange(faucetPort)
80
+ ) {
81
+ return faucetPort;
82
+ }
83
+
84
+ // Try other offsets
85
+ const offsets = [1000, 1002, 1003, 1004, 1005, 999, 998, 997];
86
+ for (const offset of offsets) {
87
+ faucetPort = rpcPort + offset;
88
+ if (
89
+ !this.isPortUsed(faucetPort, usedPorts) &&
90
+ this.isPortInRange(faucetPort)
91
+ ) {
92
+ return faucetPort;
93
+ }
94
+ }
95
+
96
+ // Search in the entire range
97
+ for (let port = this.portRangeStart; port <= this.portRangeEnd; port++) {
98
+ if (!this.isPortUsed(port, usedPorts)) {
99
+ return port;
100
+ }
101
+ }
102
+
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * Find the next available port pair
108
+ */
109
+ private findNextAvailablePorts(usedPorts: Set<number>): PortAllocation {
110
+ // Start from default ports if available
111
+ if (!this.isPortUsed(this.defaultRpcPort, usedPorts)) {
112
+ const faucetPort = this.findAvailableFaucetPort(
113
+ this.defaultRpcPort,
114
+ usedPorts
115
+ );
116
+ if (faucetPort) {
117
+ return { rpcPort: this.defaultRpcPort, faucetPort };
118
+ }
119
+ }
120
+
121
+ // Search for available RPC port
122
+ for (
123
+ let rpcPort = this.portRangeStart;
124
+ rpcPort <= this.portRangeEnd;
125
+ rpcPort++
126
+ ) {
127
+ if (!this.isPortUsed(rpcPort, usedPorts)) {
128
+ const faucetPort = this.findAvailableFaucetPort(rpcPort, usedPorts);
129
+ if (faucetPort) {
130
+ return { rpcPort, faucetPort };
131
+ }
132
+ }
133
+ }
134
+
135
+ throw new Error("No available port pairs found in the specified range");
136
+ }
137
+
138
+ /**
139
+ * Check if port is within allowed range
140
+ */
141
+ private isPortInRange(port: number): boolean {
142
+ return port >= this.portRangeStart && port <= this.portRangeEnd;
143
+ }
144
+
145
+ /**
146
+ * Actually check if a port is free by attempting to bind to it
147
+ */
148
+ private async checkPortActuallyFree(port: number): Promise<boolean> {
149
+ return new Promise((resolve) => {
150
+ const net = require("net");
151
+ const server = net.createServer();
152
+
153
+ server.listen(port, (err: any) => {
154
+ if (err) {
155
+ resolve(false);
156
+ } else {
157
+ server.once("close", () => resolve(true));
158
+ server.close();
159
+ }
160
+ });
161
+
162
+ server.on("error", () => resolve(false));
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Get recommended ports for a configuration
168
+ */
169
+ async getRecommendedPorts(config: {
170
+ localnet: { port: number; faucetPort: number };
171
+ }): Promise<PortAllocation> {
172
+ return this.getAvailablePorts(config.localnet.port);
173
+ }
174
+ }
175
+
176
+ // Singleton instance
177
+ export const portManager = new PortManager();