Selaa lähdekoodia

Improve generated CI from feedback (#65)

Loris Leiva 1 vuosi sitten
vanhempi
sitoutus
f3d5e887d2

+ 5 - 0
.changeset/hot-suns-marry.md

@@ -0,0 +1,5 @@
+---
+"create-solana-program": patch
+---
+
+Improve generated CI from feedback

+ 3 - 0
scripts/snapshot.mjs

@@ -82,6 +82,9 @@ for (const projectName of projects) {
   // Lint and test clients.
   for (const client of CLIENTS) {
     if (`clients:${client}:test` in pkg.scripts) {
+      await executeStep(`format ${client} client`, async () => {
+        await $`pnpm clients:${client}:format`;
+      });
       await executeStep(`lint ${client} client`, async () => {
         await $`pnpm clients:${client}:lint`;
       });

+ 98 - 12
template/base/.github/actions/setup/action.yml.njk

@@ -3,41 +3,127 @@ name: Setup environment
 inputs:
 {% if programFramework === 'anchor' %}
   anchor:
-    description: The Anchor version to install
+    description: The Anchor version to install. Skips if not provided.
+    required: false
 {% endif %}
-  cache:
-    description: Enable caching
-    default: "true"
+  cargo-cache-key:
+    description: The key to cache cargo dependencies. Skips cargo caching if not provided.
+    required: false
+  cargo-cache-fallback-key:
+    description: The fallback key to use when caching cargo dependencies. Default to not using a fallback key.
+    required: false
+  cargo-cache-local-key:
+    description: The key to cache local cargo dependencies. Skips local cargo caching if not provided.
+    required: false
+  clippy:
+    description: Install Clippy if `true`. Defaults to `false`.
+    required: false
   node:
-    description: The Node.js version to install
+    description: The Node.js version to install. Required.
     required: true
+  rustfmt:
+    description: Install Rustfmt if `true`. Defaults to `false`.
+    required: false
   solana:
-    description: The Solana version to install
+    description: The Solana version to install. Skips if not provided.
+    required: false
 
 runs:
-  using: "composite"
+  using: 'composite'
   steps:
     - name: Setup pnpm
       uses: pnpm/action-setup@v3
+
     - name: Setup Node.js
       uses: actions/setup-node@v4
       with:
         node-version: {% raw %}${{ inputs.node }}{% endraw %}
-        cache: "pnpm"
-    - name: Install dependencies
+        cache: 'pnpm'
+
+    - name: Install Dependencies
       run: pnpm install --frozen-lockfile
       shell: bash
+
+    - name: Set Environment Variables
+      shell: bash
+      run: pnpm zx ./scripts/ci/set-env.mjs
+
+{% if solanaVersion === '2.0' %}
+    - name: Install Protobuf Compiler (Temporary Workaround for Solana 2.0)
+      if: {% raw %}${{ inputs.solana || inputs.rustfmt == 'true' || inputs.clippy == 'true' }}{% endraw %}
+      shell: bash
+      run: |
+        sudo apt-get update
+        sudo apt-get install -y protobuf-compiler
+{% endif %}
+
+    - name: Install Rustfmt
+      if: {% raw %}${{ inputs.rustfmt == 'true' }}{% endraw %}
+      uses: dtolnay/rust-toolchain@master
+      with:
+        toolchain: {% raw %}${{ env.TOOLCHAIN_FORMAT }}{% endraw %}
+        components: rustfmt
+
+    - name: Install Clippy
+      if: {% raw %}${{ inputs.clippy == 'true' }}{% endraw %}
+      uses: dtolnay/rust-toolchain@master
+      with:
+        toolchain: {% raw %}${{ env.TOOLCHAIN_LINT }}{% endraw %}
+        components: clippy
+
     - name: Install Solana
-      if: {% raw %}${{ inputs.solana != '' }}{% endraw %}
+      if: {% raw %}${{ inputs.solana }}{% endraw %}
       uses: metaplex-foundation/actions/install-solana@v1
       with:
         version: {% raw %}${{ inputs.solana }}{% endraw %}
-        cache: {% raw %}${{ inputs.cache }}{% endraw %}
+        cache: true
+
 {% if programFramework === 'anchor' %}
     - name: Install Anchor
       if: {% raw %}${{ inputs.anchor != '' }}{% endraw %}
       uses: metaplex-foundation/actions/install-anchor-cli@v1
       with:
         version: {% raw %}${{ inputs.anchor }}{% endraw %}
-        cache: {% raw %}${{ inputs.cache }}{% endraw %}
+        cache: true
 {% endif %}
+
+    - name: Cache Cargo Dependencies
+      if: {% raw %}${{ inputs.cargo-cache-key && !inputs.cargo-cache-fallback-key }}{% endraw %}
+      uses: actions/cache@v4
+      with:
+        path: |
+          ~/.cargo/bin/
+          ~/.cargo/registry/index/
+          ~/.cargo/registry/cache/
+          ~/.cargo/git/db/
+          target/
+        key: {% raw %}${{ runner.os }}-${{ inputs.cargo-cache-key }}-${{ hashFiles('**/Cargo.lock') }}{% endraw %}
+        restore-keys: {% raw %}${{ runner.os }}-${{ inputs.cargo-cache-key }}{% endraw %}
+
+    - name: Cache Cargo Dependencies With Fallback
+      if: {% raw %}${{ inputs.cargo-cache-key && inputs.cargo-cache-fallback-key }}{% endraw %}
+      uses: actions/cache@v4
+      with:
+        path: |
+          ~/.cargo/bin/
+          ~/.cargo/registry/index/
+          ~/.cargo/registry/cache/
+          ~/.cargo/git/db/
+          target/
+        key: {% raw %}${{ runner.os }}-${{ inputs.cargo-cache-key }}-${{ hashFiles('**/Cargo.lock') }}{% endraw %}
+        restore-keys: |
+          {% raw %}${{ runner.os }}-${{ inputs.cargo-cache-key }}{% endraw %}
+          {% raw %}${{ runner.os }}-${{ inputs.cargo-cache-fallback-key }}-${{ hashFiles('**/Cargo.lock') }}{% endraw %}
+          {% raw %}${{ runner.os }}-${{ inputs.cargo-cache-fallback-key }}{% endraw %}
+
+    - name: Cache Local Cargo Dependencies
+      if: {% raw %}${{ inputs.cargo-cache-local-key }}{% endraw %}
+      uses: actions/cache@v4
+      with:
+        path: |
+          .cargo/bin/
+          .cargo/registry/index/
+          .cargo/registry/cache/
+          .cargo/git/db/
+        key: {% raw %}${{ runner.os }}-${{ inputs.cargo-cache-local-key }}-${{ hashFiles('**/Cargo.lock') }}{% endraw %}
+        restore-keys: {% raw %}${{ runner.os }}-${{ inputs.cargo-cache-local-key }}{% endraw %}

+ 129 - 109
template/base/.github/workflows/main.yml.njk

@@ -12,203 +12,223 @@ env:
 {% if programFramework === 'anchor' %}
   ANCHOR_VERSION: 0.30.0
 {% endif %}
-  CARGO_CACHE: |
-    ~/.cargo/bin/
-    ~/.cargo/registry/index/
-    ~/.cargo/registry/cache/
-    ~/.cargo/git/db/
-    target/
 
 jobs:
+  format_and_lint_programs:
+    name: Format & Lint Programs
+    runs-on: ubuntu-latest
+    steps:
+      - name: Git Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup Environment
+        uses: ./.github/actions/setup
+        with:
+          clippy: true
+          node: {% raw %}${{ env.NODE_VERSION }}{% endraw %}
+          rustfmt: true
+
+      - name: Format Programs
+        run: pnpm programs:format
+
+      - name: Lint Programs
+        run: pnpm programs:lint
+
+{% if jsClient %}
+  format_and_lint_client_js:
+    name: Format & Lint Client JS
+    runs-on: ubuntu-latest
+    steps:
+      - name: Git Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup Environment
+        uses: ./.github/actions/setup
+        with:
+          node: {% raw %}${{ env.NODE_VERSION }}{% endraw %}
+
+      - name: Format Client JS
+        run: pnpm clients:js:format
+
+      - name: Lint Client JS
+        run: pnpm clients:js:lint
+{% endif %}
+
+{% if rustClient %}
+  format_and_lint_client_rust:
+    name: Format & Lint Client Rust
+    runs-on: ubuntu-latest
+    steps:
+      - name: Git Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup Environment
+        uses: ./.github/actions/setup
+        with:
+          clippy: true
+          node: {% raw %}${{ env.NODE_VERSION }}{% endraw %}
+          rustfmt: true
+
+      - name: Format Client Rust
+        run: pnpm clients:rust:format
+
+      - name: Lint Client Rust
+        run: pnpm clients:rust:lint
+{% endif %}
+
   build_programs:
     name: Build programs
     runs-on: ubuntu-latest
+    needs: format_and_lint_programs
     steps:
-      - name: Git checkout
+      - name: Git Checkout
         uses: actions/checkout@v4
-      - name: Setup environment
+
+      - name: Setup Environment
         uses: ./.github/actions/setup
         with:
+          cargo-cache-key: cargo-programs
           node: {% raw %}${{ env.NODE_VERSION }}{% endraw %}
           solana: {% raw %}${{ env.SOLANA_VERSION }}{% endraw %}
 {% if programFramework === 'anchor' %}
           anchor: {% raw %}${{ env.ANCHOR_VERSION }}{% endraw %}
 {% endif %}
-      - name: Cache cargo dependencies
-        uses: actions/cache@v4
-        with:
-          path: {% raw %}${{ env.CARGO_CACHE }}{% endraw %}
-          key: {% raw %}${{ runner.os }}-cargo-programs-${{ hashFiles('**/Cargo.lock') }}{% endraw %}
-          restore-keys: {% raw %}${{ runner.os }}-cargo-programs{% endraw %}
-      - name: Build programs
+
+      - name: Build Programs
         run: pnpm programs:build
-      - name: Upload program builds
+
+      - name: Upload Program Builds
         uses: actions/upload-artifact@v4
         with:
           name: program-builds
           path: ./target/deploy/*.so
           if-no-files-found: error
-      - name: Save all builds for clients
+
+      - name: Save Program Builds For Client Jobs
         uses: actions/cache/save@v4
         with:
           path: ./**/*.so
           key: {% raw %}${{ runner.os }}-builds-${{ github.sha }}{% endraw %}
 
   test_programs:
-    name: Test programs
+    name: Test Progams
     runs-on: ubuntu-latest
+    needs: format_and_lint_programs
     steps:
-      - name: Git checkout
+      - name: Git Checkout
         uses: actions/checkout@v4
-      - name: Setup environment
+
+      - name: Setup Environment
         uses: ./.github/actions/setup
         with:
+          cargo-cache-key: cargo-program-tests
+          cargo-cache-fallback-key: cargo-programs
           node: {% raw %}${{ env.NODE_VERSION }}{% endraw %}
           solana: {% raw %}${{ env.SOLANA_VERSION }}{% endraw %}
 {% if programFramework === 'anchor' %}
           anchor: {% raw %}${{ env.ANCHOR_VERSION }}{% endraw %}
 {% endif %}
-      - name: Cache test cargo dependencies
-        uses: actions/cache@v4
-        with:
-          path: {% raw %}${{ env.CARGO_CACHE }}{% endraw %}
-          key: {% raw %}${{ runner.os }}-cargo-program-tests-${{ hashFiles('**/Cargo.lock') }}{% endraw %}
-          restore-keys: |
-            {% raw %}${{ runner.os }}-cargo-program-tests{% endraw %}
-            {% raw %}${{ runner.os }}-cargo-programs-${{ hashFiles('**/Cargo.lock') }}{% endraw %}
-            {% raw %}${{ runner.os }}-cargo-programs{% endraw %}
-      - name: Test programs
+
+      - name: Test Programs
         run: pnpm programs:test
 
   generate_idls:
-    name: Check IDL generation
-    needs: build_programs
+    name: Check IDL Generation
     runs-on: ubuntu-latest
+    needs: format_and_lint_programs
     steps:
-      - name: Git checkout
+      - name: Git Checkout
         uses: actions/checkout@v4
-      - name: Setup environment
+
+      - name: Setup Environment
         uses: ./.github/actions/setup
         with:
+          cargo-cache-key: cargo-programs
+          cargo-cache-local-key: cargo-local
           node: {% raw %}${{ env.NODE_VERSION }}{% endraw %}
-          solana: {% raw %}${{ env.SOLANA_VERSION }}{% endraw %}
 {% if programFramework === 'anchor' %}
           anchor: {% raw %}${{ env.ANCHOR_VERSION }}{% endraw %}
 {% endif %}
-      - name: Cache cargo dependencies
-        uses: actions/cache@v4
-        with:
-          path: {% raw %}${{ env.CARGO_CACHE }}{% endraw %}
-          key: {% raw %}${{ runner.os }}-cargo-programs-${{ hashFiles('**/Cargo.lock') }}{% endraw %}
-          restore-keys: {% raw %}${{ runner.os }}-cargo-programs{% endraw %}
-      - name: Cache local cargo dependencies
-        uses: actions/cache@v4
-        with:
-          path: |
-            .cargo/bin/
-            .cargo/registry/index/
-            .cargo/registry/cache/
-            .cargo/git/db/
-          key: {% raw %}${{ runner.os }}-cargo-local-${{ hashFiles('**/Cargo.lock') }}{% endraw %}
-          restore-keys: {% raw %}${{ runner.os }}-cargo-local{% endraw %}
+
       - name: Generate IDLs
         run: pnpm generate:idls
-      - name: Ensure working directory is clean
-        run: test -z "$(git status --porcelain)"
+
+      - name: Check Working Directory
+        run: |
+          git status --porcelain
+          test -z "$(git status --porcelain)"
 
 {% if clients.length > 0 %}
   generate_clients:
-    name: Check client generation
-    needs: build_programs
+    name: Check Client Generation
     runs-on: ubuntu-latest
+    needs: format_and_lint_programs
     steps:
-      - name: Git checkout
+      - name: Git Checkout
         uses: actions/checkout@v4
-      - name: Setup environment
+
+      - name: Setup Environment
         uses: ./.github/actions/setup
         with:
           node: {% raw %}${{ env.NODE_VERSION }}{% endraw %}
-          solana: {% raw %}${{ env.SOLANA_VERSION }}{% endraw %}
-      - name: Generate clients
+          rustfmt: true
+
+      - name: Generate Clients
         run: pnpm generate:clients
-      - name: Ensure working directory is clean
-        run: test -z "$(git status --porcelain)"
+
+      - name: Check Working Directory
+        run: |
+          git status --porcelain
+          test -z "$(git status --porcelain)"
 {% endif %}
 
 {% if jsClient %}
-  test_js:
-    name: Test JS client
-    needs: build_programs
+  test_client_js:
+    name: Test Client JS
     runs-on: ubuntu-latest
+    needs: build_programs
     steps:
-      - name: Git checkout
+      - name: Git Checkout
         uses: actions/checkout@v4
-      - name: Setup environment
+
+      - name: Setup Environment
         uses: ./.github/actions/setup
         with:
           node: {% raw %}${{ env.NODE_VERSION }}{% endraw %}
           solana: {% raw %}${{ env.SOLANA_VERSION }}{% endraw %}
-      - name: Restore all builds
+
+      - name: Restore Program Builds
         uses: actions/cache/restore@v4
         with:
           path: ./**/*.so
           key: {% raw %}${{ runner.os }}-builds-${{ github.sha }}{% endraw %}
-      - name: Test JS client
-        run: pnpm clients:js:test
 
-  lint_js:
-    name: Lint JS client
-    needs: build_programs
-    runs-on: ubuntu-latest
-    steps:
-      - name: Git checkout
-        uses: actions/checkout@v4
-      - name: Setup environment
-        uses: ./.github/actions/setup
-        with:
-          node: {% raw %}${{ env.NODE_VERSION }}{% endraw %}
-      - name: Lint JS client
-        run: pnpm clients:js:lint
+      - name: Test Client JS
+        run: pnpm clients:js:test
 {% endif %}
 
 {% if rustClient %}
-  test_rust:
-    name: Test Rust client
-    needs: build_programs
+  test_client_rust:
+    name: Test Client Rust
     runs-on: ubuntu-latest
+    needs: build_programs
     steps:
-      - name: Git checkout
+      - name: Git Checkout
         uses: actions/checkout@v4
-      - name: Setup environment
+
+      - name: Setup Environment
         uses: ./.github/actions/setup
         with:
+          cargo-cache-key: cargo-rust-client
           node: {% raw %}${{ env.NODE_VERSION }}{% endraw %}
           solana: {% raw %}${{ env.SOLANA_VERSION }}{% endraw %}
-      - name: Cache Rust client dependencies
-        uses: actions/cache@v4
-        with:
-          path: {% raw %}${{ env.CARGO_CACHE }}{% endraw %}
-          key: {% raw %}${{ runner.os }}-cargo-rust-client-${{ hashFiles('**/Cargo.lock') }}{% endraw %}
-          restore-keys: {% raw %}${{ runner.os }}-cargo-rust-client{% endraw %}
-      - name: Restore all builds
+
+      - name: Restore Program Builds
         uses: actions/cache/restore@v4
         with:
           path: ./**/*.so
           key: {% raw %}${{ runner.os }}-builds-${{ github.sha }}{% endraw %}
-      - name: Test Rust client
-        run: pnpm clients:rust:test
 
-  lint_rust:
-    name: Lint Rust client
-    needs: build_programs
-    runs-on: ubuntu-latest
-    steps:
-      - name: Git checkout
-        uses: actions/checkout@v4
-      - name: Setup environment
-        uses: ./.github/actions/setup
-        with:
-          node: {% raw %}${{ env.NODE_VERSION }}{% endraw %}
-      - name: Lint Rust client
-        run: pnpm clients:rust:lint
+      - name: Test Client Rust
+        run: pnpm clients:rust:test
 {% endif %}

+ 0 - 3
template/base/Cargo.toml

@@ -1,3 +0,0 @@
-[workspace]
-resolver = "2"
-members = ["program"]

+ 9 - 0
template/base/Cargo.toml.njk

@@ -0,0 +1,9 @@
+[workspace]
+resolver = "2"
+members = ["program"]
+
+# Specify Rust toolchains for rustfmt, clippy, and build.
+# Any unprovided toolchains default to stable.
+[workspace.metadata.toolchains]
+format = "{{ toolchain }}"
+lint = "{{ toolchain }}"

+ 5 - 0
template/base/scripts/ci/set-env.mjs

@@ -0,0 +1,5 @@
+#!/usr/bin/env zx
+import { getToolchain } from '../utils.mjs';
+
+await $`echo "TOOLCHAIN_FORMAT=${getToolchain('format')}" >> $GITHUB_ENV`;
+await $`echo "TOOLCHAIN_LINT=${getToolchain('lint')}" >> $GITHUB_ENV`;

+ 16 - 5
template/base/scripts/program/build.mjs

@@ -1,12 +1,23 @@
 #!/usr/bin/env zx
 import 'zx/globals';
-import { workingDirectory, getProgramFolders } from '../utils.mjs';
+import {
+  cliArguments,
+  getProgramFolders,
+  workingDirectory,
+} from '../utils.mjs';
 
 // Save external programs binaries to the output directory.
 import './dump.mjs';
 
+// Configure additional arguments here, e.g.:
+// ['--arg1', '--arg2', ...cliArguments()]
+const buildArgs = cliArguments();
+
 // Build the programs.
-for (const folder of getProgramFolders()) {
-  cd(`${path.join(workingDirectory, folder)}`);
-  await $`cargo-build-sbf ${process.argv.slice(3)}`;
-}
+await Promise.all(
+  getProgramFolders().map(async (folder) => {
+    const manifestPath = path.join(workingDirectory, folder, 'Cargo.toml');
+
+    await $`cargo-build-sbf --manifest-path ${manifestPath} ${buildArgs}`;
+  })
+);

+ 27 - 5
template/base/scripts/program/format.mjs

@@ -1,9 +1,31 @@
 #!/usr/bin/env zx
 import 'zx/globals';
-import { workingDirectory, getProgramFolders } from '../utils.mjs';
+import {
+  cliArguments,
+  getProgramFolders,
+  getToolchainArgument,
+  partitionArguments,
+  popArgument,
+  workingDirectory,
+} from '../utils.mjs';
+
+// Configure additional arguments here, e.g.:
+// ['--arg1', '--arg2', ...cliArguments()]
+const formatArgs = cliArguments();
+
+const fix = popArgument(formatArgs, '--fix');
+const [cargoArgs, fmtArgs] = partitionArguments(formatArgs, '--');
+const toolchain = getToolchainArgument('format');
 
 // Format the programs.
-for (const folder of getProgramFolders()) {
-  cd(`${path.join(workingDirectory, folder)}`);
-  await $`cargo fmt ${process.argv.slice(3)}`;
-}
+await Promise.all(
+  getProgramFolders().map(async (folder) => {
+    const manifestPath = path.join(workingDirectory, folder, 'Cargo.toml');
+
+    if (fix) {
+      await $`cargo ${toolchain} fmt --manifest-path ${manifestPath} ${cargoArgs} -- ${fmtArgs}`;
+    } else {
+      await $`cargo ${toolchain} fmt --manifest-path ${manifestPath} ${cargoArgs} -- --check ${fmtArgs}`;
+    }
+  })
+);

+ 25 - 5
template/base/scripts/program/lint.mjs

@@ -1,9 +1,29 @@
 #!/usr/bin/env zx
 import 'zx/globals';
-import { workingDirectory, getProgramFolders } from '../utils.mjs';
+import {
+  cliArguments,
+  getProgramFolders,
+  getToolchainArgument,
+  popArgument,
+  workingDirectory,
+} from '../utils.mjs';
+
+// Configure additional arguments here, e.g.:
+// ['--arg1', '--arg2', ...cliArguments()]
+const lintArgs = cliArguments();
+
+const fix = popArgument(lintArgs, '--fix');
+const toolchain = getToolchainArgument('format');
 
 // Lint the programs using clippy.
-for (const folder of getProgramFolders()) {
-  cd(`${path.join(workingDirectory, folder)}`);
-  await $`cargo clippy ${process.argv.slice(3)}`;
-}
+await Promise.all(
+  getProgramFolders().map(async (folder) => {
+    const manifestPath = path.join(workingDirectory, folder, 'Cargo.toml');
+
+    if (fix) {
+      await $`cargo ${toolchain} clippy --manifest-path ${manifestPath} --fix ${lintArgs}`;
+    } else {
+      await $`cargo ${toolchain} clippy --manifest-path ${manifestPath} ${lintArgs}`;
+    }
+  })
+);

+ 20 - 9
template/base/scripts/program/test.mjs

@@ -1,18 +1,29 @@
 #!/usr/bin/env zx
 import 'zx/globals';
-import { workingDirectory, getProgramFolders } from '../utils.mjs';
+import {
+  cliArguments,
+  getProgramFolders,
+  workingDirectory,
+} from '../utils.mjs';
 
 // Save external programs binaries to the output directory.
 import './dump.mjs';
 
+// Configure additional arguments here, e.g.:
+// ['--arg1', '--arg2', ...cliArguments()]
+const testArgs = cliArguments();
+
 const hasSolfmt = await which('solfmt', { nothrow: true });
+
 // Test the programs.
-for (const folder of getProgramFolders()) {
-  cd(`${path.join(workingDirectory, folder)}`);
+await Promise.all(
+  getProgramFolders().map(async (folder) => {
+    const manifestPath = path.join(workingDirectory, folder, 'Cargo.toml');
 
-  if (hasSolfmt) {
-    await $`RUST_LOG=error cargo test-sbf ${process.argv.slice(3)} 2>&1 | solfmt`;
-  } else {
-    await $`RUST_LOG=error cargo test-sbf ${process.argv.slice(3)}`;
-  }
-}
+    if (hasSolfmt) {
+      await $`RUST_LOG=error cargo test-sbf --manifest-path ${manifestPath} ${testArgs} 2>&1 | solfmt`;
+    } else {
+      await $`RUST_LOG=error cargo test-sbf --manifest-path ${manifestPath} ${testArgs}`;
+    }
+  })
+);

+ 36 - 6
template/base/scripts/utils.mjs

@@ -13,23 +13,20 @@ export function getAllProgramIdls() {
 }
 
 export function getExternalProgramOutputDir() {
-  const config =
-    getCargo().workspace?.metadata?.solana?.['external-programs-output'];
+  const config = getCargoMetadata()?.solana?.['external-programs-output'];
   return path.join(workingDirectory, config ?? 'target/deploy');
 }
 
 export function getExternalProgramAddresses() {
   const addresses = getProgramFolders().flatMap(
-    (folder) =>
-      getCargo(folder).package?.metadata?.solana?.['program-dependencies'] ?? []
+    (folder) => getCargoMetadata(folder)?.solana?.['program-dependencies'] ?? []
   );
   return Array.from(new Set(addresses));
 }
 
 export function getExternalAccountAddresses() {
   const addresses = getProgramFolders().flatMap(
-    (folder) =>
-      getCargo(folder).package?.metadata?.solana?.['account-dependencies'] ?? []
+    (folder) => getCargoMetadata(folder)?.solana?.['account-dependencies'] ?? []
   );
   return Array.from(new Set(addresses));
 }
@@ -81,3 +78,36 @@ export function getCargo(folder) {
     )
   );
 }
+
+export function getCargoMetadata(folder) {
+  const cargo = getCargo(folder);
+  return folder ? cargo?.package?.metadata : cargo?.workspace?.metadata;
+}
+
+export function getToolchain(operation) {
+  return getCargoMetadata()?.toolchains?.[operation];
+}
+
+export function getToolchainArgument(operation) {
+  const channel = getToolchain(operation);
+  return channel ? `+${channel}` : '';
+}
+
+export function cliArguments() {
+  return process.argv.slice(3);
+}
+
+export function popArgument(args, arg) {
+  const index = args.indexOf(arg);
+  if (index >= 0) {
+    args.splice(index, 1);
+  }
+  return index >= 0;
+}
+
+export function partitionArguments(args, delimiter) {
+  const index = args.indexOf(delimiter);
+  return index >= 0
+    ? [args.slice(0, index), args.slice(index + 1)]
+    : [args, []];
+}

+ 6 - 4
template/clients/base/scripts/generate-clients.mjs.njk

@@ -11,13 +11,15 @@ import { renderVisitor as renderRustVisitor } from '@kinobi-so/renderers-rust';
 import { getAllProgramIdls } from './utils.mjs';
 
 // Instanciate Kinobi.
-const [idl, ...additionalIdls] = getAllProgramIdls().map(idl => rootNodeFromAnchor(require(idl)))
+const [idl, ...additionalIdls] = getAllProgramIdls().map((idl) =>
+  rootNodeFromAnchor(require(idl))
+);
 const kinobi = k.createFromRoot(idl, additionalIdls);
 
 // Update programs.
 kinobi.update(
   k.updateProgramsVisitor({
-    '{{ programCrateName | camelCase }}': { name: '{{ programName | camelCase }}' },
+    {{ programCrateName | camelCase }}: { name: '{{ programName | camelCase }}' },
   })
 );
 
@@ -72,8 +74,8 @@ kinobi.update(
 // Render JavaScript.
 const jsClient = path.join(__dirname, '..', 'clients', 'js');
 kinobi.accept(
-  renderJavaScriptVisitor(path.join(jsClient, 'src', 'generated'), { 
-    prettierOptions: require(path.join(jsClient, '.prettierrc.json'))
+  renderJavaScriptVisitor(path.join(jsClient, 'src', 'generated'), {
+    prettierOptions: require(path.join(jsClient, '.prettierrc.json')),
   })
 );
 {% endif %}

+ 1 - 0
template/clients/js/package.json

@@ -1,5 +1,6 @@
 {
   "scripts": {
+    "clients:js:format": "zx ./scripts/client/format-js.mjs",
     "clients:js:lint": "zx ./scripts/client/lint-js.mjs",
     "clients:js:test": "zx ./scripts/client/test-js.mjs"
   },

+ 8 - 0
template/clients/js/scripts/client/format-js.mjs

@@ -0,0 +1,8 @@
+#!/usr/bin/env zx
+import 'zx/globals';
+import { cliArguments, workingDirectory } from '../utils.mjs';
+
+// Format the client using Prettier.
+cd(path.join(workingDirectory, 'clients', 'js'));
+await $`pnpm install`;
+await $`pnpm format ${cliArguments()}`;

+ 3 - 4
template/clients/js/scripts/client/lint-js.mjs

@@ -1,9 +1,8 @@
 #!/usr/bin/env zx
 import 'zx/globals';
-import { workingDirectory } from '../utils.mjs';
+import { cliArguments, workingDirectory } from '../utils.mjs';
 
-// Check the client using ESLint and Prettier.
+// Check the client using ESLint.
 cd(path.join(workingDirectory, 'clients', 'js'));
 await $`pnpm install`;
-await $`pnpm lint`;
-await $`pnpm format`;
+await $`pnpm lint ${cliArguments()}`;

+ 3 - 3
template/clients/js/scripts/client/test-js.mjs

@@ -1,12 +1,12 @@
 #!/usr/bin/env zx
 import 'zx/globals';
-import { workingDirectory } from '../utils.mjs';
+import { cliArguments, workingDirectory } from '../utils.mjs';
 
-// Start the local validator if it's not already running.
+// Start the local validator, or restart it if it is already running.
 await $`pnpm validator:restart`;
 
 // Build the client and run the tests.
 cd(path.join(workingDirectory, 'clients', 'js'));
 await $`pnpm install`;
 await $`pnpm build`;
-await $`pnpm test ${process.argv.slice(3)}`;
+await $`pnpm test ${cliArguments()}`;

+ 1 - 0
template/clients/rust/package.json

@@ -1,5 +1,6 @@
 {
   "scripts": {
+    "clients:rust:format": "zx ./scripts/client/format-rust.mjs",
     "clients:rust:lint": "zx ./scripts/client/lint-rust.mjs",
     "clients:rust:test": "zx ./scripts/client/test-rust.mjs"
   },

+ 30 - 0
template/clients/rust/scripts/client/format-rust.mjs

@@ -0,0 +1,30 @@
+#!/usr/bin/env zx
+import 'zx/globals';
+import {
+  cliArguments,
+  getToolchainArgument,
+  partitionArguments,
+  popArgument,
+  workingDirectory,
+} from '../utils.mjs';
+
+// Configure additional arguments here, e.g.:
+// ['--arg1', '--arg2', ...cliArguments()]
+const formatArgs = cliArguments();
+
+const fix = popArgument(formatArgs, '--fix');
+const [cargoArgs, fmtArgs] = partitionArguments(formatArgs, '--');
+const toolchain = getToolchainArgument('format');
+const manifestPath = path.join(
+  workingDirectory,
+  'clients',
+  'rust',
+  'Cargo.toml'
+);
+
+// Format the client.
+if (fix) {
+  await $`cargo ${toolchain} fmt --manifest-path ${manifestPath} ${cargoArgs} -- ${fmtArgs}`;
+} else {
+  await $`cargo ${toolchain} fmt --manifest-path ${manifestPath} ${cargoArgs} -- --check ${fmtArgs}`;
+}

+ 24 - 3
template/clients/rust/scripts/client/lint-rust.mjs

@@ -1,7 +1,28 @@
 #!/usr/bin/env zx
 import 'zx/globals';
-import { workingDirectory } from '../utils.mjs';
+import {
+  cliArguments,
+  getToolchainArgument,
+  popArgument,
+  workingDirectory,
+} from '../utils.mjs';
+
+// Configure additional arguments here, e.g.:
+// ['--arg1', '--arg2', ...cliArguments()]
+const lintArgs = cliArguments();
+
+const fix = popArgument(lintArgs, '--fix');
+const toolchain = getToolchainArgument('format');
+const manifestPath = path.join(
+  workingDirectory,
+  'clients',
+  'rust',
+  'Cargo.toml'
+);
 
 // Check the client using Clippy.
-cd(path.join(workingDirectory, 'clients', 'rust'));
-await $`cargo clippy ${process.argv.slice(3)}`;
+if (fix) {
+  await $`cargo ${toolchain} clippy --manifest-path ${manifestPath} --fix ${lintArgs}`;
+} else {
+  await $`cargo ${toolchain} clippy --manifest-path ${manifestPath} ${lintArgs}`;
+}

+ 9 - 4
template/clients/rust/scripts/client/test-rust.mjs

@@ -1,12 +1,17 @@
 #!/usr/bin/env zx
 import 'zx/globals';
-import { workingDirectory } from '../utils.mjs';
+import { cliArguments, workingDirectory } from '../utils.mjs';
+
+// Configure additional arguments here, e.g.:
+// ['--arg1', '--arg2', ...cliArguments()]
+const testArgs = cliArguments();
+
+const hasSolfmt = await which('solfmt', { nothrow: true });
 
 // Run the tests.
 cd(path.join(workingDirectory, 'clients', 'rust'));
-const hasSolfmt = await which('solfmt', { nothrow: true });
 if (hasSolfmt) {
-  await $`cargo test-sbf ${process.argv.slice(3)} 2>&1 | solfmt`;
+  await $`cargo test-sbf ${testArgs} 2>&1 | solfmt`;
 } else {
-  await $`cargo test-sbf ${process.argv.slice(3)}`;
+  await $`cargo test-sbf ${testArgs}`;
 }

+ 17 - 3
utils/getRenderContext.ts

@@ -23,6 +23,7 @@ export type RenderContext = Omit<Inputs, 'programAddress' | 'solanaVersion'> & {
   solanaVersionDetected: string;
   targetDirectory: string;
   templateDirectory: string;
+  toolchain: string;
 };
 
 export function getRenderContext({
@@ -44,6 +45,10 @@ export function getRenderContext({
   );
   const getNpmCommand: RenderContext['getNpmCommand'] = (...args) =>
     getPackageManagerCommand(packageManager, ...args);
+  const solanaVersion =
+    inputs.solanaVersion ??
+    toMinorSolanaVersion(language, solanaVersionDetected);
+  const toolchain = getToolchainFromSolanaVersion(solanaVersion);
 
   // Directories.
   const templateDirectory = path.resolve(__dirname, 'template');
@@ -66,11 +71,20 @@ export function getRenderContext({
     packageManager,
     programAddress,
     programDirectory,
-    solanaVersion:
-      inputs.solanaVersion ??
-      toMinorSolanaVersion(language, solanaVersionDetected),
+    solanaVersion,
     solanaVersionDetected,
     targetDirectory,
     templateDirectory,
+    toolchain,
   };
 }
+
+function getToolchainFromSolanaVersion(solanaVersion: string): string {
+  const map: Record<string, string> = {
+    '1.17': '1.68.0',
+    '1.18': '1.75.0',
+    '2.0': '1.75.0',
+  };
+
+  return map[solanaVersion] ?? '1.75.0';
+}