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.
- package/LICENSE +21 -0
- package/README.md +441 -0
- package/package.json +71 -0
- package/scripts/postinstall.cjs +103 -0
- package/src/commands/add-program.ts +337 -0
- package/src/commands/init.ts +122 -0
- package/src/commands/list.ts +136 -0
- package/src/commands/start.ts +735 -0
- package/src/commands/status.ts +99 -0
- package/src/commands/stop.ts +389 -0
- package/src/commands/transfer.ts +259 -0
- package/src/config/manager.ts +157 -0
- package/src/index.ts +107 -0
- package/src/services/port-manager.ts +177 -0
- package/src/services/process-registry.ts +155 -0
- package/src/services/program-cloner.ts +317 -0
- package/src/services/token-cloner.ts +809 -0
- package/src/services/validator.ts +295 -0
- package/src/types/config.ts +110 -0
- package/src/utils/shell.ts +110 -0
- package/tsconfig.json +28 -0
|
@@ -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();
|