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,