Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add the `dusk-forge` CLI with `new`, `build`, `test`, and `check` commands for contract project scaffolding and workflows.

### Changed

- Make local forge path overrides opt-in for release builds and harden CLI template/path handling across platforms.

### Fixed

- Make `dusk-forge build data-driver` select the supported project feature (`data-driver-js` or `data-driver`) instead of hardcoding the JS variant.

## [0.2.2] - 2026-02-02

### Changed
Expand Down
13 changes: 12 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ dusk-core = { workspace = true }

[workspace]
resolver = "2"
members = ["contract-macro", "tests/types", "tests/test-bridge"]
members = ["contract-macro", "tests/types", "tests/test-bridge", "cli"]
exclude = ["contract-template"]

[workspace.package]
Expand All @@ -45,6 +45,16 @@ serde_json = { version = "1", default-features = false }
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full", "visit"] }
# Keep this on a pre-edition-2024 release until the workspace toolchain moves
# forward; newer releases require a newer Cargo parser than this branch uses.
assert_cmd = "=2.1.1"
cargo_metadata = "0.19"
clap = { version = "4", features = ["derive"] }
colored = "3"
predicates = "3"
tempfile = "=3.10.1"
thiserror = "2"
toml = "0.8"

# Pin to match L1Contracts versions
dusk-vm = { version = "=1.4.0", default-features = false }
Expand All @@ -53,6 +63,7 @@ blake2b_simd = "=1.0.2"
blake3 = "=1.5.4"
constant_time_eq = "=0.3.1"
time-core = "=0.1.6"
uuid = "=1.20.0"

# Enable overflow checks in release builds for contract safety
[profile.release]
Expand Down
24 changes: 24 additions & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[package]
name = "dusk-forge-cli"
version = "0.1.0"
edition = "2021"
description = "CLI for scaffolding and building Dusk Forge contracts"
license = "MPL-2.0"
repository = "https://github.com/dusk-network/forge"

[[bin]]
name = "dusk-forge"
path = "src/main.rs"

[dependencies]
cargo_metadata = { workspace = true }
clap = { workspace = true, features = ["derive"] }
colored = { workspace = true }
serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
toml = { workspace = true }

[dev-dependencies]
assert_cmd = { workspace = true }
predicates = { workspace = true }
tempfile = { workspace = true }
99 changes: 99 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# dusk-forge CLI

`dusk-forge` is the command-line interface for scaffolding and working with Dusk Forge smart contracts.

Naming note:
- Cargo package name: `dusk-forge-cli`
- Installed executable: `dusk-forge`

## Install

Install from this local repo:

```bash
cargo install --path cli
```

Install from Git (after this is pushed):

```bash
cargo install --git https://github.com/dusk-network/forge dusk-forge-cli
```

Both commands install the `dusk-forge` binary into `~/.cargo/bin`. Ensure that directory is on your `PATH`.

## Build

From the workspace root:

```bash
cargo build -p dusk-forge-cli
```

Run directly:

```bash
cargo run -p dusk-forge-cli -- --help
```

## Commands

- `dusk-forge new <name>`: scaffold a new contract project.
- `dusk-forge build [target]`: build WASM artifacts. Targets: `all` (default), `contract`, `data-driver`.
- `dusk-forge test [-- <cargo-test-args>]`: build contract WASM and run `cargo test --release`.
- `dusk-forge check`: validate project structure and toolchain.

## Common Options

Project-scoped commands support:

- `--path <dir>`: contract project directory (defaults to current directory).
- `--verbose`: print executed command details.

## Examples

Create and build a project:

```bash
dusk-forge new my-counter --no-git
cd my-counter

dusk-forge check
dusk-forge build
dusk-forge build contract
dusk-forge test
```

## Toolchain Requirements

Contract builds require:

- The toolchain configured in `rust-toolchain.toml` (generated by `dusk-forge new`)
- `wasm32-unknown-unknown` target for that toolchain
- `rust-src` component for that toolchain
- No implicit fallback toolchain is used; if `rust-toolchain.toml` is missing, commands fail.

Data-driver builds require:

- The toolchain configured in `rust-toolchain.toml`
- `wasm32-unknown-unknown` target for that toolchain

Optional tools:

- `wasm-opt` for smaller WASM artifacts

## Template Notes

`new` supports built-in templates:

- `--template counter` (default)
- `--template empty`

Contract name validation accepts lowercase kebab-case names (`[a-z0-9-]`, must start with a letter).

Scaffolded projects include:

- `rust-toolchain.toml` for deterministic toolchain selection
- `Cargo.lock` generated at scaffold time

Build/test commands run Cargo with `--locked`.
193 changes: 193 additions & 0 deletions cli/src/build_runner/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
pub mod wasm_opt;

use std::{
env,
ffi::OsStr,
path::{Path, PathBuf},
process::{Command, Stdio},
};

use crate::{
error::{CliError, Result},
project::detect,
project::metadata::ProjectMetadata,
toolchain::{self, WASM_TARGET},
};

const CONTRACT_FEATURE: &str = "contract";
const STACK_SIZE: u32 = 65_536;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuildTarget {
Contract,
DataDriver,
}

impl BuildTarget {
pub fn label(self) -> &'static str {
match self {
Self::Contract => "contract",
Self::DataDriver => "data-driver",
}
}

pub fn wasm_path(self, project: &ProjectMetadata) -> PathBuf {
match self {
Self::Contract => project.contract_wasm_path.clone(),
Self::DataDriver => project.data_driver_wasm_path.clone(),
}
}
}

pub fn build(project: &ProjectMetadata, target: BuildTarget, verbose: bool) -> Result<PathBuf> {
let mut cmd = Command::new("cargo");
let toolchain_arg = toolchain::cargo_toolchain_arg(&project.project_dir)?;
let feature = match target {
BuildTarget::Contract => CONTRACT_FEATURE,
BuildTarget::DataDriver => detect::resolve_data_driver_feature(&project.project_dir)?,
};

cmd.arg(&toolchain_arg)
.arg("build")
.arg("--release")
.arg("--locked")
.arg("--target")
.arg(WASM_TARGET)
.arg("--features")
.arg(feature)
.arg("--manifest-path")
.arg(&project.manifest_path)
.arg("--color=always");

if target == BuildTarget::Contract {
cmd.arg("-Z").arg("build-std=core,alloc");
}

let target_dir = match target {
BuildTarget::Contract => &project.contract_target_dir,
BuildTarget::DataDriver => &project.data_driver_target_dir,
};

cmd.env("CARGO_TARGET_DIR", target_dir)
.env("RUSTFLAGS", compose_rustflags(target))
.current_dir(&project.project_dir)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.stdin(Stdio::inherit());
apply_local_forge_overrides(&mut cmd, verbose);

if verbose {
eprintln!("Running: {}", crate::ui::format_command(&cmd));
}

let status = cmd.status()?;
if !status.success() {
return Err(CliError::CommandFailed {
program: "cargo build".to_string(),
code: status.code().unwrap_or(1),
});
}

let wasm_path = target.wasm_path(project);
ensure_file_exists(&wasm_path)?;

Ok(wasm_path)
}

pub fn apply_local_forge_overrides(cmd: &mut Command, verbose: bool) {
let mut applied = Vec::new();

if let Some((forge_root, macro_root)) = local_forge_paths() {
append_patch_config(cmd, "dusk-forge", &forge_root);
append_patch_config(cmd, "dusk-forge-contract", &macro_root);
applied.push(format!("dusk-forge -> {}", forge_root.display()));
applied.push(format!("dusk-forge-contract -> {}", macro_root.display()));
}

if verbose && !applied.is_empty() {
eprintln!("Applying local overrides: {}", applied.join(", "));
}
}

fn local_forge_paths() -> Option<(PathBuf, PathBuf)> {
if !should_apply_local_forge_overrides() {
return None;
}

let cli_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let forge_root = cli_dir.parent()?.to_path_buf();
let macro_root = forge_root.join("contract-macro");

if forge_root.join("Cargo.toml").is_file() && macro_root.join("Cargo.toml").is_file() {
Some((forge_root, macro_root))
} else {
None
}
}

fn append_patch_config(cmd: &mut Command, crate_name: &str, path: &Path) {
let path_escaped = toml_escape(path.as_os_str());
cmd.arg("--config").arg(format!(
"patch.crates-io.{crate_name}.path=\"{path_escaped}\""
));
}

fn should_apply_local_forge_overrides() -> bool {
cfg!(debug_assertions) || env_flag("DUSK_FORGE_DEV")
}

fn env_flag(name: &str) -> bool {
env::var_os(name).is_some_and(|value| !value.is_empty())
}

fn toml_escape(value: &OsStr) -> String {
let raw = value.to_string_lossy();
raw.replace('\\', "\\\\").replace('"', "\\\"")
}

fn compose_rustflags(target: BuildTarget) -> String {
let mut parts: Vec<String> = env::var("RUSTFLAGS")
.ok()
.map(|existing| {
existing
.split_whitespace()
.map(ToString::to_string)
.collect()
})
.unwrap_or_default();

if let Some(home) = home_dir_from_env() {
parts.push("--remap-path-prefix".to_string());
parts.push(format!("{home}="));
}

if target == BuildTarget::Contract {
parts.push("-C".to_string());
parts.push(format!("link-args=-zstack-size={STACK_SIZE}"));
}

parts.join(" ")
}

fn home_dir_from_env() -> Option<String> {
for key in ["HOME", "USERPROFILE"] {
if let Ok(value) = env::var(key) {
if !value.is_empty() {
return Some(value);
}
}
}

None
}

fn ensure_file_exists(path: &Path) -> Result<()> {
if path.exists() {
Ok(())
} else {
Err(CliError::Message(format!(
"expected build artifact not found: {}",
path.display()
)))
}
}
Loading
Loading