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,735 @@
1
+ import chalk from "chalk";
2
+ import ora from "ora";
3
+ import { spawn } from "child_process";
4
+ import { existsSync, readFileSync } from "fs";
5
+ import { join } from "path";
6
+ import { runCommand, checkSolanaTools } from "../utils/shell.js";
7
+ import { configManager } from "../config/manager.js";
8
+ import { TokenCloner } from "../services/token-cloner.js";
9
+ import { ProgramCloner } from "../services/program-cloner.js";
10
+ import { processRegistry } from "../services/process-registry.js";
11
+ import { portManager } from "../services/port-manager.js";
12
+ import type { Config, TokenConfig, ProgramConfig } from "../types/config.js";
13
+ import type { ClonedToken } from "../services/token-cloner.js";
14
+ import type { RunningValidator } from "../services/process-registry.js";
15
+
16
+ function generateValidatorId(name: string): string {
17
+ const timestamp = Date.now();
18
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
19
+ const safeName = name.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase();
20
+ return `${safeName}-${timestamp}-${randomSuffix}`;
21
+ }
22
+
23
+ export async function startCommand(debug: boolean = false): Promise<void> {
24
+ // Check prerequisites
25
+ const tools = await checkSolanaTools();
26
+ if (!tools.solana) {
27
+ console.error(chalk.red("āŒ solana CLI not found"));
28
+ console.log(
29
+ chalk.yellow(
30
+ "šŸ’” Install it with: sh -c \"$(curl --proto '=https' --tlsv1.2 -sSfL https://solana-install.solana.workers.dev | bash)\""
31
+ )
32
+ );
33
+ process.exit(1);
34
+ }
35
+
36
+ // Load configuration
37
+ let config: Config;
38
+ try {
39
+ await configManager.load("./sf.config.json");
40
+ config = configManager.getConfig();
41
+ } catch (error) {
42
+ console.error(chalk.red("āŒ Failed to load sf.config.json"));
43
+ console.error(
44
+ chalk.red(error instanceof Error ? error.message : String(error))
45
+ );
46
+ console.log(
47
+ chalk.yellow("šŸ’” Run `solforge init` to create a configuration")
48
+ );
49
+ process.exit(1);
50
+ }
51
+
52
+ // Check if validator is already running on configured ports first
53
+ const checkResult = await runCommand(
54
+ "curl",
55
+ [
56
+ "-s",
57
+ "-X",
58
+ "POST",
59
+ `http://127.0.0.1:${config.localnet.port}`,
60
+ "-H",
61
+ "Content-Type: application/json",
62
+ "-d",
63
+ '{"jsonrpc":"2.0","id":1,"method":"getHealth"}',
64
+ ],
65
+ { silent: true, debug: false }
66
+ );
67
+
68
+ if (checkResult.success && checkResult.stdout.includes("ok")) {
69
+ console.log(chalk.yellow("āš ļø Validator is already running"));
70
+ console.log(
71
+ chalk.cyan(`🌐 RPC URL: http://127.0.0.1:${config.localnet.port}`)
72
+ );
73
+ console.log(
74
+ chalk.cyan(
75
+ `šŸ’° Faucet URL: http://127.0.0.1:${config.localnet.faucetPort}`
76
+ )
77
+ );
78
+
79
+ // Clone tokens if needed, even when validator is already running
80
+ let clonedTokens: ClonedToken[] = [];
81
+ if (config.tokens.length > 0) {
82
+ const tokenCloner = new TokenCloner();
83
+
84
+ // Check which tokens are already cloned and which need to be cloned
85
+ const { existingTokens, tokensToClone } = await checkExistingClonedTokens(
86
+ config.tokens,
87
+ tokenCloner
88
+ );
89
+
90
+ if (existingTokens.length > 0) {
91
+ console.log(
92
+ chalk.green(`šŸ“ Found ${existingTokens.length} already cloned tokens`)
93
+ );
94
+ if (debug) {
95
+ existingTokens.forEach((token: ClonedToken) => {
96
+ console.log(
97
+ chalk.gray(
98
+ ` āœ“ ${token.config.symbol} (${token.config.mainnetMint})`
99
+ )
100
+ );
101
+ });
102
+ }
103
+ clonedTokens.push(...existingTokens);
104
+ }
105
+
106
+ if (tokensToClone.length > 0) {
107
+ console.log(
108
+ chalk.yellow(
109
+ `šŸ“¦ Cloning ${tokensToClone.length} new tokens from mainnet...\n`
110
+ )
111
+ );
112
+ try {
113
+ const newlyClonedTokens = await tokenCloner.cloneTokens(
114
+ tokensToClone,
115
+ config.localnet.rpc,
116
+ debug
117
+ );
118
+ clonedTokens.push(...newlyClonedTokens);
119
+ console.log(
120
+ chalk.green(
121
+ `āœ… Successfully cloned ${newlyClonedTokens.length} new tokens\n`
122
+ )
123
+ );
124
+ } catch (error) {
125
+ console.error(chalk.red("āŒ Failed to clone tokens:"));
126
+ console.error(
127
+ chalk.red(error instanceof Error ? error.message : String(error))
128
+ );
129
+ console.log(
130
+ chalk.yellow(
131
+ "šŸ’” You can start without tokens by removing them from sf.config.json"
132
+ )
133
+ );
134
+ process.exit(1);
135
+ }
136
+ } else if (existingTokens.length > 0) {
137
+ console.log(
138
+ chalk.green("āœ… All tokens already cloned, skipping clone step\n")
139
+ );
140
+ }
141
+ }
142
+
143
+ // Airdrop SOL to mint authority if tokens were cloned (even when validator already running)
144
+ if (clonedTokens.length > 0) {
145
+ console.log(chalk.yellow("\nšŸ’ø Airdropping SOL to mint authority..."));
146
+ const rpcUrl = `http://127.0.0.1:${config.localnet.port}`;
147
+
148
+ try {
149
+ await airdropSolToMintAuthority(clonedTokens[0], rpcUrl, debug);
150
+ console.log(chalk.green("āœ… SOL airdropped successfully!"));
151
+ } catch (error) {
152
+ console.error(chalk.red("āŒ Failed to airdrop SOL:"));
153
+ console.error(
154
+ chalk.red(error instanceof Error ? error.message : String(error))
155
+ );
156
+ console.log(
157
+ chalk.yellow(
158
+ "šŸ’” You may need to manually airdrop SOL for fee payments"
159
+ )
160
+ );
161
+ }
162
+ }
163
+
164
+ // Still mint tokens if any were cloned
165
+ if (clonedTokens.length > 0) {
166
+ console.log(chalk.yellow("\nšŸ’° Minting tokens..."));
167
+ const tokenCloner = new TokenCloner();
168
+ const rpcUrl = `http://127.0.0.1:${config.localnet.port}`;
169
+
170
+ if (debug) {
171
+ console.log(
172
+ chalk.gray(`šŸ› Minting ${clonedTokens.length} tokens to recipients:`)
173
+ );
174
+ clonedTokens.forEach((token, index) => {
175
+ console.log(
176
+ chalk.gray(
177
+ ` ${index + 1}. ${token.config.symbol} (${
178
+ token.config.mainnetMint
179
+ }) - ${token.config.mintAmount} tokens`
180
+ )
181
+ );
182
+ });
183
+ console.log(chalk.gray(`🌐 Using RPC: ${rpcUrl}`));
184
+ }
185
+
186
+ try {
187
+ await tokenCloner.mintTokensToRecipients(clonedTokens, rpcUrl, debug);
188
+ console.log(chalk.green("āœ… Token minting completed!"));
189
+
190
+ if (debug) {
191
+ console.log(
192
+ chalk.gray(
193
+ "šŸ› All tokens have been minted to their respective recipients"
194
+ )
195
+ );
196
+ }
197
+ } catch (error) {
198
+ console.error(chalk.red("āŒ Failed to mint tokens:"));
199
+ console.error(
200
+ chalk.red(error instanceof Error ? error.message : String(error))
201
+ );
202
+ console.log(
203
+ chalk.yellow("šŸ’” Validator is still running, you can mint manually")
204
+ );
205
+ }
206
+ }
207
+ return;
208
+ }
209
+
210
+ // Generate unique ID for this validator instance
211
+ const validatorId = generateValidatorId(config.name);
212
+
213
+ // Get available ports (only if validator is not already running)
214
+ const ports = await portManager.getRecommendedPorts(config);
215
+ if (
216
+ ports.rpcPort !== config.localnet.port ||
217
+ ports.faucetPort !== config.localnet.faucetPort
218
+ ) {
219
+ console.log(
220
+ chalk.yellow(
221
+ `āš ļø Configured ports not available, using: RPC ${ports.rpcPort}, Faucet ${ports.faucetPort}`
222
+ )
223
+ );
224
+ // Update config with available ports
225
+ config.localnet.port = ports.rpcPort;
226
+ config.localnet.faucetPort = ports.faucetPort;
227
+ }
228
+
229
+ console.log(chalk.blue(`šŸš€ Starting ${config.name} (${validatorId})...\n`));
230
+ console.log(chalk.gray(`šŸ“” RPC Port: ${config.localnet.port}`));
231
+ console.log(chalk.gray(`šŸ’° Faucet Port: ${config.localnet.faucetPort}\n`));
232
+
233
+ // Programs will be cloned automatically by validator using --clone-program flags
234
+ if (config.programs.length > 0) {
235
+ console.log(
236
+ chalk.cyan(
237
+ `šŸ”§ Will clone ${config.programs.length} programs from mainnet during startup\n`
238
+ )
239
+ );
240
+ }
241
+
242
+ // Clone tokens after programs
243
+ let clonedTokens: ClonedToken[] = [];
244
+ if (config.tokens.length > 0) {
245
+ const tokenCloner = new TokenCloner();
246
+
247
+ // Check which tokens are already cloned and which need to be cloned
248
+ const { existingTokens, tokensToClone } = await checkExistingClonedTokens(
249
+ config.tokens,
250
+ tokenCloner
251
+ );
252
+
253
+ if (existingTokens.length > 0) {
254
+ console.log(
255
+ chalk.green(`šŸ“ Found ${existingTokens.length} already cloned tokens`)
256
+ );
257
+ if (debug) {
258
+ existingTokens.forEach((token: ClonedToken) => {
259
+ console.log(
260
+ chalk.gray(
261
+ ` āœ“ ${token.config.symbol} (${token.config.mainnetMint})`
262
+ )
263
+ );
264
+ });
265
+ }
266
+ clonedTokens.push(...existingTokens);
267
+ }
268
+
269
+ if (tokensToClone.length > 0) {
270
+ console.log(
271
+ chalk.yellow(
272
+ `šŸ“¦ Cloning ${tokensToClone.length} new tokens from mainnet...\n`
273
+ )
274
+ );
275
+ try {
276
+ const newlyClonedTokens = await tokenCloner.cloneTokens(
277
+ tokensToClone,
278
+ config.localnet.rpc,
279
+ debug
280
+ );
281
+ clonedTokens.push(...newlyClonedTokens);
282
+ console.log(
283
+ chalk.green(
284
+ `āœ… Successfully cloned ${newlyClonedTokens.length} new tokens\n`
285
+ )
286
+ );
287
+ } catch (error) {
288
+ console.error(chalk.red("āŒ Failed to clone tokens:"));
289
+ console.error(
290
+ chalk.red(error instanceof Error ? error.message : String(error))
291
+ );
292
+ console.log(
293
+ chalk.yellow(
294
+ "šŸ’” You can start without tokens by removing them from sf.config.json"
295
+ )
296
+ );
297
+ process.exit(1);
298
+ }
299
+ } else if (existingTokens.length > 0) {
300
+ console.log(
301
+ chalk.green("āœ… All tokens already cloned, skipping clone step\n")
302
+ );
303
+ }
304
+ }
305
+
306
+ // Build validator command arguments
307
+ const args = buildValidatorArgs(config, clonedTokens);
308
+
309
+ console.log(chalk.gray("Command to run:"));
310
+ console.log(chalk.gray(`solana-test-validator ${args.join(" ")}\n`));
311
+
312
+ if (debug) {
313
+ console.log(chalk.yellow("šŸ› Debug mode enabled"));
314
+ console.log(chalk.gray("Full command details:"));
315
+ console.log(chalk.gray(` Command: solana-test-validator`));
316
+ console.log(chalk.gray(` Arguments: ${JSON.stringify(args, null, 2)}`));
317
+ }
318
+
319
+ // Start the validator
320
+ const spinner = ora("Starting Solana test validator...").start();
321
+
322
+ try {
323
+ // Start validator in background
324
+ const validatorProcess = await startValidatorInBackground(
325
+ "solana-test-validator",
326
+ args,
327
+ debug
328
+ );
329
+
330
+ // Wait for validator to be ready
331
+ spinner.text = "Waiting for validator to be ready...";
332
+ await waitForValidatorReady(
333
+ `http://127.0.0.1:${config.localnet.port}`,
334
+ debug
335
+ );
336
+
337
+ // Register the running validator
338
+ const runningValidator: RunningValidator = {
339
+ id: validatorId,
340
+ name: config.name,
341
+ pid: validatorProcess.pid!,
342
+ rpcPort: config.localnet.port,
343
+ faucetPort: config.localnet.faucetPort,
344
+ rpcUrl: `http://127.0.0.1:${config.localnet.port}`,
345
+ faucetUrl: `http://127.0.0.1:${config.localnet.faucetPort}`,
346
+ configPath: configManager.getConfigPath() || "./sf.config.json",
347
+ startTime: new Date(),
348
+ status: "running",
349
+ };
350
+
351
+ processRegistry.register(runningValidator);
352
+
353
+ // Validator is now ready
354
+ spinner.succeed("Validator started successfully!");
355
+
356
+ console.log(chalk.green("āœ… Localnet is running!"));
357
+ console.log(chalk.gray(`šŸ†” Validator ID: ${validatorId}`));
358
+ console.log(
359
+ chalk.cyan(`🌐 RPC URL: http://127.0.0.1:${config.localnet.port}`)
360
+ );
361
+ console.log(
362
+ chalk.cyan(
363
+ `šŸ’° Faucet URL: http://127.0.0.1:${config.localnet.faucetPort}`
364
+ )
365
+ );
366
+
367
+ // Airdrop SOL to mint authority if tokens were cloned
368
+ if (clonedTokens.length > 0) {
369
+ console.log(chalk.yellow("\nšŸ’ø Airdropping SOL to mint authority..."));
370
+ const rpcUrl = `http://127.0.0.1:${config.localnet.port}`;
371
+
372
+ try {
373
+ await airdropSolToMintAuthority(clonedTokens[0], rpcUrl, debug);
374
+ console.log(chalk.green("āœ… SOL airdropped successfully!"));
375
+ } catch (error) {
376
+ console.error(chalk.red("āŒ Failed to airdrop SOL:"));
377
+ console.error(
378
+ chalk.red(error instanceof Error ? error.message : String(error))
379
+ );
380
+ console.log(
381
+ chalk.yellow(
382
+ "šŸ’” You may need to manually airdrop SOL for fee payments"
383
+ )
384
+ );
385
+ }
386
+ }
387
+
388
+ // Mint tokens if any were cloned
389
+ if (clonedTokens.length > 0) {
390
+ console.log(chalk.yellow("\nšŸ’° Minting tokens..."));
391
+ const tokenCloner = new TokenCloner();
392
+ const rpcUrl = `http://127.0.0.1:${config.localnet.port}`;
393
+
394
+ if (debug) {
395
+ console.log(
396
+ chalk.gray(`šŸ› Minting ${clonedTokens.length} tokens to recipients:`)
397
+ );
398
+ clonedTokens.forEach((token, index) => {
399
+ console.log(
400
+ chalk.gray(
401
+ ` ${index + 1}. ${token.config.symbol} (${
402
+ token.config.mainnetMint
403
+ }) - ${token.config.mintAmount} tokens`
404
+ )
405
+ );
406
+ });
407
+ console.log(chalk.gray(`🌐 Using RPC: ${rpcUrl}`));
408
+ }
409
+
410
+ try {
411
+ await tokenCloner.mintTokensToRecipients(clonedTokens, rpcUrl, debug);
412
+ console.log(chalk.green("āœ… Token minting completed!"));
413
+
414
+ if (debug) {
415
+ console.log(
416
+ chalk.gray(
417
+ "šŸ› All tokens have been minted to their respective recipients"
418
+ )
419
+ );
420
+ }
421
+ } catch (error) {
422
+ console.error(chalk.red("āŒ Failed to mint tokens:"));
423
+ console.error(
424
+ chalk.red(error instanceof Error ? error.message : String(error))
425
+ );
426
+ console.log(
427
+ chalk.yellow("šŸ’” Validator is still running, you can mint manually")
428
+ );
429
+ }
430
+ }
431
+
432
+ if (config.tokens.length > 0) {
433
+ console.log(chalk.yellow("\nšŸŖ™ Cloned tokens:"));
434
+ config.tokens.forEach((token) => {
435
+ console.log(
436
+ chalk.gray(
437
+ ` - ${token.symbol}: ${token.mainnetMint} (${token.mintAmount} minted)`
438
+ )
439
+ );
440
+ });
441
+ }
442
+
443
+ if (config.programs.length > 0) {
444
+ console.log(chalk.yellow("\nšŸ“¦ Cloned programs:"));
445
+ config.programs.forEach((program) => {
446
+ const name =
447
+ program.name || program.mainnetProgramId.slice(0, 8) + "...";
448
+ console.log(chalk.gray(` - ${name}: ${program.mainnetProgramId}`));
449
+ });
450
+ }
451
+
452
+ console.log(chalk.blue("\nšŸ’” Tips:"));
453
+ console.log(
454
+ chalk.gray(" - Run `solforge list` to see all running validators")
455
+ );
456
+ console.log(
457
+ chalk.gray(" - Run `solforge status` to check validator status")
458
+ );
459
+ console.log(
460
+ chalk.gray(
461
+ ` - Run \`solforge stop ${validatorId}\` to stop this validator`
462
+ )
463
+ );
464
+ console.log(
465
+ chalk.gray(" - Run `solforge stop --all` to stop all validators")
466
+ );
467
+ } catch (error) {
468
+ spinner.fail("Failed to start validator");
469
+ console.error(chalk.red("āŒ Unexpected error:"));
470
+ console.error(
471
+ chalk.red(error instanceof Error ? error.message : String(error))
472
+ );
473
+ process.exit(1);
474
+ }
475
+ }
476
+
477
+ function buildValidatorArgs(
478
+ config: Config,
479
+ clonedTokens: ClonedToken[] = []
480
+ ): string[] {
481
+ const args: string[] = [];
482
+
483
+ // Basic configuration
484
+ args.push("--rpc-port", config.localnet.port.toString());
485
+ args.push("--faucet-port", config.localnet.faucetPort.toString());
486
+ args.push("--bind-address", config.localnet.bindAddress);
487
+
488
+ if (config.localnet.reset) {
489
+ args.push("--reset");
490
+ }
491
+
492
+ if (config.localnet.quiet) {
493
+ args.push("--quiet");
494
+ } else {
495
+ // Always use quiet mode to prevent log spam in background
496
+ args.push("--quiet");
497
+ }
498
+
499
+ // Add ledger size limit
500
+ args.push("--limit-ledger-size", config.localnet.limitLedgerSize.toString());
501
+
502
+ // Add cloned token accounts (using modified JSON files)
503
+ if (clonedTokens.length > 0) {
504
+ const tokenCloner = new TokenCloner();
505
+ const tokenArgs = tokenCloner.getValidatorArgs(clonedTokens);
506
+ args.push(...tokenArgs);
507
+ }
508
+
509
+ // Clone programs from mainnet using built-in validator flags
510
+ for (const program of config.programs) {
511
+ if (program.upgradeable) {
512
+ args.push("--clone-upgradeable-program", program.mainnetProgramId);
513
+ } else {
514
+ // Use --clone for regular programs (non-upgradeable)
515
+ args.push("--clone", program.mainnetProgramId);
516
+ }
517
+ }
518
+
519
+ // If we're cloning programs, specify the source cluster
520
+ if (config.programs.length > 0) {
521
+ args.push("--url", config.localnet.rpc);
522
+ }
523
+
524
+ return args;
525
+ }
526
+
527
+ /**
528
+ * Start the validator in the background
529
+ */
530
+ async function startValidatorInBackground(
531
+ command: string,
532
+ args: string[],
533
+ debug: boolean
534
+ ): Promise<{ pid: number }> {
535
+ return new Promise((resolve, reject) => {
536
+ if (debug) {
537
+ console.log(chalk.gray(`Starting ${command} in background...`));
538
+ console.log(chalk.gray(`Command: ${command} ${args.join(" ")}`));
539
+ }
540
+
541
+ const child = spawn(command, args, {
542
+ detached: true,
543
+ stdio: "ignore", // Always ignore stdio to ensure it runs in background
544
+ });
545
+
546
+ child.on("error", (error) => {
547
+ reject(new Error(`Failed to start validator: ${error.message}`));
548
+ });
549
+
550
+ // Give the validator a moment to start
551
+ setTimeout(() => {
552
+ if (child.pid) {
553
+ child.unref(); // Allow parent to exit without waiting for child
554
+ if (debug) {
555
+ console.log(
556
+ chalk.gray(`āœ… Validator started with PID: ${child.pid}`)
557
+ );
558
+ }
559
+ resolve({ pid: child.pid });
560
+ } else {
561
+ reject(new Error("Validator failed to start"));
562
+ }
563
+ }, 1000);
564
+ });
565
+ }
566
+
567
+ /**
568
+ * Wait for the validator to be ready by polling the RPC endpoint
569
+ */
570
+ async function waitForValidatorReady(
571
+ rpcUrl: string,
572
+ debug: boolean,
573
+ maxAttempts: number = 30
574
+ ): Promise<void> {
575
+ let lastError: string = "";
576
+
577
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
578
+ try {
579
+ if (debug) {
580
+ console.log(
581
+ chalk.gray(`Attempt ${attempt}/${maxAttempts}: Checking ${rpcUrl}`)
582
+ );
583
+ }
584
+
585
+ const result = await runCommand(
586
+ "curl",
587
+ [
588
+ "-s",
589
+ "-X",
590
+ "POST",
591
+ rpcUrl,
592
+ "-H",
593
+ "Content-Type: application/json",
594
+ "-d",
595
+ '{"jsonrpc":"2.0","id":1,"method":"getHealth"}',
596
+ ],
597
+ { silent: true, debug: false }
598
+ );
599
+
600
+ if (result.success && result.stdout.includes("ok")) {
601
+ if (debug) {
602
+ console.log(
603
+ chalk.green(`āœ… Validator is ready after ${attempt} attempts`)
604
+ );
605
+ }
606
+ return;
607
+ }
608
+
609
+ // Store the last error for better diagnostics
610
+ if (!result.success) {
611
+ lastError = result.stderr || "Unknown error";
612
+ }
613
+ } catch (error) {
614
+ lastError = error instanceof Error ? error.message : String(error);
615
+ }
616
+
617
+ if (attempt < maxAttempts) {
618
+ await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait 1 second
619
+ }
620
+ }
621
+
622
+ // Provide better error message
623
+ let errorMsg = `Validator failed to become ready after ${maxAttempts} attempts`;
624
+ if (lastError.includes("Connection refused")) {
625
+ errorMsg += `\nšŸ’” This usually means:\n - Port ${
626
+ rpcUrl.split(":")[2]
627
+ } is already in use\n - Run 'pkill -f solana-test-validator' to kill existing validators`;
628
+ } else if (lastError) {
629
+ errorMsg += `\nLast error: ${lastError}`;
630
+ }
631
+
632
+ throw new Error(errorMsg);
633
+ }
634
+
635
+ /**
636
+ * Airdrop SOL to the mint authority for fee payments
637
+ */
638
+ async function airdropSolToMintAuthority(
639
+ clonedToken: any,
640
+ rpcUrl: string,
641
+ debug: boolean = false
642
+ ): Promise<void> {
643
+ if (debug) {
644
+ console.log(
645
+ chalk.gray(`Airdropping 10 SOL to ${clonedToken.mintAuthority.publicKey}`)
646
+ );
647
+ }
648
+
649
+ const airdropResult = await runCommand(
650
+ "solana",
651
+ ["airdrop", "10", clonedToken.mintAuthority.publicKey, "--url", rpcUrl],
652
+ { silent: !debug, debug }
653
+ );
654
+
655
+ if (!airdropResult.success) {
656
+ throw new Error(
657
+ `Failed to airdrop SOL: ${airdropResult.stderr || airdropResult.stdout}`
658
+ );
659
+ }
660
+
661
+ if (debug) {
662
+ console.log(chalk.gray("SOL airdrop completed"));
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Check for existing cloned tokens and return what's already cloned vs what needs to be cloned
668
+ */
669
+ async function checkExistingClonedTokens(
670
+ tokens: TokenConfig[],
671
+ tokenCloner: TokenCloner
672
+ ): Promise<{ existingTokens: ClonedToken[]; tokensToClone: TokenConfig[] }> {
673
+ const existingTokens: ClonedToken[] = [];
674
+ const tokensToClone: TokenConfig[] = [];
675
+ const workDir = ".solforge";
676
+
677
+ // Check for shared mint authority
678
+ const sharedMintAuthorityPath = join(workDir, "shared-mint-authority.json");
679
+ let sharedMintAuthority: { publicKey: string; secretKey: number[] } | null =
680
+ null;
681
+
682
+ if (existsSync(sharedMintAuthorityPath)) {
683
+ try {
684
+ const fileContent = JSON.parse(
685
+ readFileSync(sharedMintAuthorityPath, "utf8")
686
+ );
687
+
688
+ if (Array.isArray(fileContent)) {
689
+ // New format: file contains just the secret key array
690
+ const { Keypair } = await import("@solana/web3.js");
691
+ const keypair = Keypair.fromSecretKey(new Uint8Array(fileContent));
692
+ sharedMintAuthority = {
693
+ publicKey: keypair.publicKey.toBase58(),
694
+ secretKey: Array.from(keypair.secretKey),
695
+ };
696
+
697
+ // Check metadata for consistency
698
+ const metadataPath = join(workDir, "shared-mint-authority-meta.json");
699
+ if (existsSync(metadataPath)) {
700
+ const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
701
+ if (metadata.publicKey !== sharedMintAuthority.publicKey) {
702
+ sharedMintAuthority.publicKey = metadata.publicKey;
703
+ }
704
+ }
705
+ } else {
706
+ // Old format: file contains {publicKey, secretKey}
707
+ sharedMintAuthority = fileContent;
708
+ }
709
+ } catch (error) {
710
+ // If we can't read the shared mint authority, treat all tokens as needing to be cloned
711
+ sharedMintAuthority = null;
712
+ }
713
+ }
714
+
715
+ for (const token of tokens) {
716
+ const tokenDir = join(workDir, `token-${token.symbol.toLowerCase()}`);
717
+ const modifiedAccountPath = join(tokenDir, "modified.json");
718
+
719
+ // Check if this token has already been cloned
720
+ if (existsSync(modifiedAccountPath) && sharedMintAuthority) {
721
+ // Token appears to be already cloned
722
+ existingTokens.push({
723
+ config: token,
724
+ mintAuthorityPath: sharedMintAuthorityPath,
725
+ modifiedAccountPath,
726
+ mintAuthority: sharedMintAuthority,
727
+ });
728
+ } else {
729
+ // Token needs to be cloned
730
+ tokensToClone.push(token);
731
+ }
732
+ }
733
+
734
+ return { existingTokens, tokensToClone };
735
+ }