From edc5617797df7affd579e3ca06837bae86a6df00 Mon Sep 17 00:00:00 2001
From: Loris Leiva <loris.leiva@gmail.com>
Date: Mon, 20 Jan 2025 16:11:41 +0100
Subject: [PATCH] [wip]: Prepare JS cli

---
 clients/js/package.json          |   7 +-
 clients/js/pnpm-lock.yaml        |  29 ++-
 clients/js/src/cli.ts            | 362 +++++++++++++++++++++++++++++++
 clients/js/src/types/global.d.ts |   1 +
 clients/js/tsup.config.ts        |   3 +
 5 files changed, 397 insertions(+), 5 deletions(-)
 create mode 100644 clients/js/src/cli.ts
 create mode 100644 clients/js/src/types/global.d.ts

diff --git a/clients/js/package.json b/clients/js/package.json
index 7ffa340..2812ee3 100644
--- a/clients/js/package.json
+++ b/clients/js/package.json
@@ -14,6 +14,9 @@
       "require": "./dist/src/index.js"
     }
   },
+  "bin": {
+    "program-metadata": "./dist/src/cli.js"
+  },
   "files": [
     "./dist/src",
     "./dist/types"
@@ -46,7 +49,9 @@
   },
   "dependencies": {
     "@solana-program/system": "^0.6.2",
-    "pako": "^2.1.0"
+    "commander": "^13.0.0",
+    "pako": "^2.1.0",
+    "yaml": "^2.7.0"
   },
   "devDependencies": {
     "@ava/typescript": "^4.1.0",
diff --git a/clients/js/pnpm-lock.yaml b/clients/js/pnpm-lock.yaml
index 394a7b9..e89104f 100644
--- a/clients/js/pnpm-lock.yaml
+++ b/clients/js/pnpm-lock.yaml
@@ -11,9 +11,15 @@ importers:
       '@solana-program/system':
         specifier: ^0.6.2
         version: 0.6.2(@solana/web3.js@2.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.7.3)(ws@8.18.0))
+      commander:
+        specifier: ^13.0.0
+        version: 13.0.0
       pako:
         specifier: ^2.1.0
         version: 2.1.0
+      yaml:
+        specifier: ^2.7.0
+        version: 2.7.0
     devDependencies:
       '@ava/typescript':
         specifier: ^4.1.0
@@ -50,7 +56,7 @@ importers:
         version: 5.0.10
       tsup:
         specifier: ^8.1.2
-        version: 8.3.5(typescript@5.7.3)
+        version: 8.3.5(typescript@5.7.3)(yaml@2.7.0)
       typedoc:
         specifier: ^0.25.12
         version: 0.25.13(typescript@5.7.3)
@@ -927,6 +933,10 @@ packages:
     resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
     engines: {node: '>=18'}
 
+  commander@13.0.0:
+    resolution: {integrity: sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==}
+    engines: {node: '>=18'}
+
   commander@4.1.1:
     resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
     engines: {node: '>= 6'}
@@ -1941,6 +1951,11 @@ packages:
     resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
     engines: {node: '>=18'}
 
+  yaml@2.7.0:
+    resolution: {integrity: sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==}
+    engines: {node: '>= 14'}
+    hasBin: true
+
   yargs-parser@21.1.1:
     resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
     engines: {node: '>=12'}
@@ -2902,6 +2917,8 @@ snapshots:
 
   commander@12.1.0: {}
 
+  commander@13.0.0: {}
+
   commander@4.1.1: {}
 
   common-path-prefix@3.0.0: {}
@@ -3519,9 +3536,11 @@ snapshots:
     dependencies:
       irregular-plurals: 3.5.0
 
-  postcss-load-config@6.0.1:
+  postcss-load-config@6.0.1(yaml@2.7.0):
     dependencies:
       lilconfig: 3.1.3
+    optionalDependencies:
+      yaml: 2.7.0
 
   prelude-ls@1.2.1: {}
 
@@ -3731,7 +3750,7 @@ snapshots:
 
   tslib@1.14.1: {}
 
-  tsup@8.3.5(typescript@5.7.3):
+  tsup@8.3.5(typescript@5.7.3)(yaml@2.7.0):
     dependencies:
       bundle-require: 5.1.0(esbuild@0.24.2)
       cac: 6.7.14
@@ -3741,7 +3760,7 @@ snapshots:
       esbuild: 0.24.2
       joycon: 3.1.1
       picocolors: 1.1.1
-      postcss-load-config: 6.0.1
+      postcss-load-config: 6.0.1(yaml@2.7.0)
       resolve-from: 5.0.0
       rollup: 4.30.1
       source-map: 0.8.0-beta.0
@@ -3842,6 +3861,8 @@ snapshots:
 
   yallist@5.0.0: {}
 
+  yaml@2.7.0: {}
+
   yargs-parser@21.1.1: {}
 
   yargs@17.7.2:
diff --git a/clients/js/src/cli.ts b/clients/js/src/cli.ts
new file mode 100644
index 0000000..d15c01b
--- /dev/null
+++ b/clients/js/src/cli.ts
@@ -0,0 +1,362 @@
+#!/usr/bin/env node
+
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+import {
+  Commitment,
+  createKeyPairSignerFromBytes,
+  createSolanaRpc,
+  createSolanaRpcSubscriptions,
+  KeyPairSigner,
+  Rpc,
+  RpcSubscriptions,
+  SolanaRpcApi,
+  SolanaRpcSubscriptionsApi,
+} from '@solana/web3.js';
+import { Command } from 'commander';
+import { parse as parseYaml } from 'yaml';
+
+const LOCALHOST_URL = 'http://127.0.0.1:8899';
+const LOCALHOST_WEBSOCKET_URL = 'ws://127.0.0.1:8900';
+
+// Define the CLI program.
+const program = new Command();
+program
+  .name('program-metadata')
+  .description('CLI to manage Solana program metadata and IDLs')
+  .version(__VERSION__)
+  .option(
+    '-k, --keypair <path>',
+    'Path to keypair file (default to solana config)'
+  )
+  .option(
+    '-p, --payer <path>',
+    'Path to keypair file of transaction fee and storage payer (default to keypair)'
+  )
+  .option('--rpc <string>', 'RPC URL (default to solana config or localhost)')
+  .option(
+    '--priority-fees <number>',
+    'Priority fees per compute unit for sending transactions',
+    '100000'
+  );
+
+// Upload metadata command.
+program
+  .command('upload <seed> <program-id> <content>')
+  .description('Upload metadata')
+  // .option(
+  //   '-a, --add-signer-seed',
+  //   "Add signer's public key as additional seed. This will create a non associated metadata account. ",
+  //   false
+  // )
+  // .option(
+  //   '--export-transaction',
+  //   'Only create buffer and export setBuffer transaction'
+  // )
+  .action(async (/*file, programId, options*/) => {
+    // TODO: Ask to confirm before creating a non-canonical metadata account.
+    // try {
+    //   const rpcUrl = getRpcUrl(options);
+    //   const keypair = options.keypair
+    //     ? Keypair.fromSecretKey(
+    //         new Uint8Array(
+    //           JSON.parse(fs.readFileSync(options.keypair, 'utf-8'))
+    //         )
+    //       )
+    //     : loadDefaultKeypair();
+    //   const isAuthority = await checkProgramAuthority(
+    //     new PublicKey(programId),
+    //     keypair.publicKey,
+    //     rpcUrl
+    //   );
+    //   if (!isAuthority) {
+    //     console.warn(AUTHORITY_WARNING_MESSAGE);
+    //     return;
+    //   }
+    //   const result = await uploadIdlByJsonPath(
+    //     file,
+    //     new PublicKey(programId),
+    //     keypair,
+    //     rpcUrl,
+    //     parseInt(options.priorityFees),
+    //     options.addSignerSeed,
+    //     options.exportTransaction
+    //   );
+    //   if (options.exportTransaction && result) {
+    //     console.log(
+    //       'Exported setBuffer transaction with programAuthority as signer:'
+    //     );
+    //     console.log('Base58:', result.base58);
+    //     console.log('Base64:', result.base64);
+    //   } else {
+    //     console.log('IDL uploaded successfully!');
+    //   }
+    // } catch (error) {
+    //   console.error(
+    //     'Error:',
+    //     error instanceof Error ? error.message : 'Unknown error occurred'
+    //   );
+    //   process.exit(1);
+    // }
+  });
+
+program
+  .command('download <seed> <program-id> [output]')
+  .description('Download IDL to file')
+  .option(
+    '-s, --signer <pubkey>',
+    'Additional signer public key to find non-associated PDAs'
+  )
+  .action(async (/*seed, programId, output, options*/) => {
+    // try {
+    //   const rpcUrl = getRpcUrl(options);
+    //   const signerPubkey = options.signer
+    //     ? new PublicKey(options.signer)
+    //     : undefined;
+    //   const idl = await fetchIDL(
+    //     new PublicKey(programId),
+    //     rpcUrl,
+    //     signerPubkey
+    //   );
+    //   if (!idl) {
+    //     throw new Error('No IDL found');
+    //   }
+    //   fs.writeFileSync(output, idl ?? '');
+    //   console.log(`IDL downloaded to ${output}`);
+    // } catch (error) {
+    //   console.error(
+    //     'Error:',
+    //     error instanceof Error ? error.message : 'Unknown error occurred'
+    //   );
+    //   process.exit(1);
+    // }
+  });
+
+program
+  .command('close <seed> <program-id>')
+  .description('Close metadata account and recover rent')
+  .action(async () => {
+    // try {
+    //   const rpcUrl = getRpcUrl(options);
+    //   const keypair = options.keypair
+    //     ? Keypair.fromSecretKey(
+    //         new Uint8Array(
+    //           JSON.parse(fs.readFileSync(options.keypair, 'utf-8'))
+    //         )
+    //       )
+    //     : loadDefaultKeypair();
+    //   const isAuthority = await checkProgramAuthority(
+    //     new PublicKey(programId),
+    //     keypair.publicKey,
+    //     rpcUrl
+    //   );
+    //   if (!isAuthority) {
+    //     console.warn(AUTHORITY_WARNING_MESSAGE);
+    //     return;
+    //   }
+    //   await closeProgramMetadata2(
+    //     new PublicKey(programId),
+    //     keypair,
+    //     rpcUrl,
+    //     options.seed,
+    //     parseInt(options.priorityFees),
+    //     options.addSignerSeed
+    //   );
+    //   console.log('Metadata account closed successfully!');
+    // } catch (error) {
+    //   console.error(
+    //     'Error:',
+    //     error instanceof Error ? error.message : 'Unknown error occurred'
+    //   );
+    //   process.exit(1);
+    // }
+  });
+
+program
+  .command('list-buffers')
+  .description('List all buffer accounts owned by an authority')
+  .action(async () => {
+    //     try {
+    //       const rpcUrl = getRpcUrl(options);
+    //       const keypair = options.keypair
+    //         ? Keypair.fromSecretKey(
+    //             new Uint8Array(
+    //               JSON.parse(fs.readFileSync(options.keypair, 'utf-8'))
+    //             )
+    //           )
+    //         : loadDefaultKeypair();
+    //       const buffers = await listBuffers(keypair.publicKey, rpcUrl);
+    //       if (buffers.length === 0) {
+    //         console.log('No buffers found for this authority');
+    //         return;
+    //       }
+    //       console.log('\nFound buffers:');
+    //       buffers.forEach(
+    //         ({
+    //           address,
+    //           dataLength,
+    //           dataType,
+    //           encoding,
+    //           compression,
+    //           format,
+    //           dataSource,
+    //         }) => {
+    //           console.log(`\n
+    // Address: ${address.toBase58()}
+    // Data Length: ${dataLength} bytes
+    // Data Type: ${dataType}
+    // Encoding: ${JSON.stringify(encoding, null, 2)}
+    // Compression: ${JSON.stringify(compression, null, 2)}
+    // Format: ${JSON.stringify(format, null, 2)}
+    // Data Source: ${JSON.stringify(dataSource, null, 2)}
+    // `);
+    //         }
+    //       );
+    //     } catch (error) {
+    //       console.error(
+    //         'Error:',
+    //         error instanceof Error ? error.message : 'Unknown error occurred'
+    //       );
+    //       process.exit(1);
+    //     }
+  });
+
+program
+  .command('list')
+  .description('List all metadata PDAs owned by an authority')
+  .action(async () => {
+    //     try {
+    //       const rpcUrl = getRpcUrl(options);
+    //       const keypair = options.keypair
+    //         ? Keypair.fromSecretKey(
+    //             new Uint8Array(
+    //               JSON.parse(fs.readFileSync(options.keypair, 'utf-8'))
+    //             )
+    //           )
+    //         : loadDefaultKeypair();
+    //       const pdas = await listPDAs(keypair.publicKey, rpcUrl);
+    //       if (pdas.length === 0) {
+    //         console.log('No PDAs found for this authority');
+    //         return;
+    //       }
+    //       console.log('\nFound PDAs:');
+    //       pdas.forEach(
+    //         ({
+    //           address,
+    //           dataLength,
+    //           dataType,
+    //           programId,
+    //           encoding,
+    //           compression,
+    //           format,
+    //           dataSource,
+    //         }) => {
+    //           console.log(
+    //             `\n
+    // Address: ${address.toBase58()}
+    // Program ID: ${programId.toBase58()}
+    // Data Length: ${dataLength} bytes
+    // Data Type: ${dataType}
+    // Encoding: ${JSON.stringify(encoding, null, 2)}
+    // Compression: ${JSON.stringify(compression, null, 2)}
+    // Format: ${JSON.stringify(format, null, 2)}
+    // Data Source: ${JSON.stringify(dataSource, null, 2)}`
+    //           );
+    //         }
+    //       );
+    //     } catch (error) {
+    //       console.error(
+    //         'Error:',
+    //         error instanceof Error ? error.message : 'Unknown error occurred'
+    //       );
+    //       process.exit(1);
+    //     }
+  });
+
+export async function getKeyPairSigners(
+  options: { keypair?: string; payer?: string },
+  configs: SolanaConfigs
+): Promise<[KeyPairSigner, KeyPairSigner]> {
+  const keypairPath = getKeyPairPath(options, configs);
+  const keypairPromise = getKeyPairSignerFromPath(keypairPath);
+  const payerPromise = options.payer
+    ? getKeyPairSignerFromPath(options.payer)
+    : keypairPromise;
+  return await Promise.all([keypairPromise, payerPromise]);
+}
+
+function getKeyPairPath(
+  options: { keypair?: string },
+  configs: SolanaConfigs
+): string {
+  if (options.keypair) return options.keypair;
+  if (configs.keypairPath) return configs.keypairPath;
+  return path.join(os.homedir(), '.config', 'solana', 'id.json');
+}
+
+async function getKeyPairSignerFromPath(
+  keypairPath: string
+): Promise<KeyPairSigner> {
+  if (!fs.existsSync(keypairPath)) {
+    throw new Error(`Keypair file not found at: ${keypairPath}`);
+  }
+  const keypairString = fs.readFileSync(keypairPath, 'utf-8');
+  const keypairData = new Uint8Array(JSON.parse(keypairString));
+  return await createKeyPairSignerFromBytes(keypairData);
+}
+
+type Client = {
+  rpc: Rpc<SolanaRpcApi>;
+  rpcSubscriptions: RpcSubscriptions<SolanaRpcSubscriptionsApi>;
+  configs: SolanaConfigs;
+};
+
+export function getClient(options: { rpc?: string }): Client {
+  const configs = getSolanaConfigs();
+  const rpcUrl = getRpcUrl(options, configs);
+  const rpcSubscriptionsUrl = getRpcSubscriptionsUrl(rpcUrl, configs);
+  return {
+    rpc: createSolanaRpc(rpcUrl),
+    rpcSubscriptions: createSolanaRpcSubscriptions(rpcSubscriptionsUrl),
+    configs,
+  };
+}
+
+function getRpcUrl(options: { rpc?: string }, configs: SolanaConfigs): string {
+  if (options.rpc) return options.rpc;
+  if (configs.jsonRpcUrl) return configs.jsonRpcUrl;
+  return LOCALHOST_URL;
+}
+
+function getRpcSubscriptionsUrl(
+  rpcUrl: string,
+  configs: SolanaConfigs
+): string {
+  if (configs.websocketUrl) return configs.websocketUrl;
+  if (rpcUrl === LOCALHOST_URL) return LOCALHOST_WEBSOCKET_URL;
+  return rpcUrl.replace(/^http/, 'ws');
+}
+
+type SolanaConfigs = {
+  jsonRpcUrl?: string;
+  websocketUrl?: string;
+  keypairPath?: string;
+  commitment?: Commitment;
+};
+
+function getSolanaConfigs(): SolanaConfigs {
+  const path = getSolanaConfigPath();
+  if (!fs.existsSync(path)) {
+    console.warn('Solana config file not found');
+    return {};
+  }
+  return parseYaml(fs.readFileSync(getSolanaConfigPath(), 'utf8'));
+}
+
+function getSolanaConfigPath(): string {
+  return path.join(os.homedir(), '.config', 'solana', 'cli', 'config.yml');
+}
+
+program.parse();
diff --git a/clients/js/src/types/global.d.ts b/clients/js/src/types/global.d.ts
new file mode 100644
index 0000000..415c2c8
--- /dev/null
+++ b/clients/js/src/types/global.d.ts
@@ -0,0 +1 @@
+declare const __VERSION__: string;
diff --git a/clients/js/tsup.config.ts b/clients/js/tsup.config.ts
index fb77d21..96695b2 100644
--- a/clients/js/tsup.config.ts
+++ b/clients/js/tsup.config.ts
@@ -15,6 +15,9 @@ export default defineConfig(() => [
   { ...SHARED_OPTIONS, format: 'cjs' },
   { ...SHARED_OPTIONS, format: 'esm' },
 
+  // CLI.
+  { ...SHARED_OPTIONS, format: 'cjs', entry: ['./src/cli.ts'] },
+
   // Tests.
   {
     ...SHARED_OPTIONS,