feat(cli): aura nodes new — node-crate scaffold + attach (#241 T5-T6)

- New clap family `aura nodes new <name> [--engine-path] [--namespace]`:
  scaffolds a node crate as a SIBLING of the project root (Cargo.toml
  with the aura-core path-dep, the parameterized Scale starter node,
  roster macro, crate-scoped .gitignore + CLAUDE.md, own git repo) and
  appends the `[nodes] crates = ["../<name>"]` pointer to the project's
  Aura.toml. Refuses outside a project, on any existing [nodes] section
  (incl. an empty crates array), and on an existing destination; joins
  the load-phase bypass beside `aura new` (a scaffolder must work in an
  unbuilt tree).
- The old single-tier scaffold() is gone; the crate templates live on
  in scaffold_node_crate; dead-code bridges removed with their callers.
- tests/project_nodes.rs: attach + refusals + namespace override + the
  full role-2 loop (scaffold, cargo build, vocabulary resolves
  <ns>::Scale end to end).
- tests/project_sweep_campaign.rs fixture reworked to the two-tier
  shape (aura new + aura nodes new --namespace knob_lab); the two
  campaign acceptance tests are byte-unchanged and green.

refs #241
This commit is contained in:
2026-07-12 17:08:47 +02:00
parent 3932333e19
commit 23bb978bf2
5 changed files with 329 additions and 71 deletions
+94 -3
View File
@@ -2455,6 +2455,8 @@ enum Command {
Reproduce(ReproduceCmd),
/// Scaffold a new research project crate.
New(NewCmd),
/// Scaffold and attach node crates (native-node development).
Nodes(NodesCmd),
/// Validate, introspect, and register process documents (methodology).
Process(research_docs::ProcessCmd),
/// Validate, introspect, and register campaign documents (experiment intent).
@@ -2479,6 +2481,30 @@ struct NewCmd {
name: String,
}
#[derive(Args)]
struct NodesCmd {
#[command(subcommand)]
command: NodesCommand,
}
#[derive(Subcommand)]
enum NodesCommand {
/// Scaffold a node crate beside this project and attach it via [nodes].
New(NodesNewCmd),
}
#[derive(Args)]
struct NodesNewCmd {
/// Crate name; also the default namespace (dashes become underscores).
name: String,
/// Engine checkout for the crate's aura-core path-dep.
#[arg(long)]
engine_path: Option<std::path::PathBuf>,
/// Vocabulary namespace override.
#[arg(long)]
namespace: Option<String>,
}
#[derive(Args)]
#[command(args_conflicts_with_subcommands = true)]
struct GraphCmd {
@@ -3332,6 +3358,70 @@ fn dispatch_new(a: NewCmd, _env: &project::Env) {
);
}
fn dispatch_nodes(cmd: NodesCmd) {
match cmd.command {
NodesCommand::New(a) => dispatch_nodes_new(a),
}
}
fn dispatch_nodes_new(a: NodesNewCmd) {
let cwd = std::env::current_dir().unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(1);
});
let Some(root) = project::discover_from(&cwd) else {
eprintln!(
"aura: `aura nodes new` needs a project (no Aura.toml found up from {})",
cwd.display()
);
std::process::exit(1);
};
let toml = match project::read_aura_toml(&root) {
Ok(t) => t,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(1);
}
};
// Mirror `append_nodes_pointer`'s textual `[nodes]`-section check (not just
// `crates.is_empty()`) so a `[nodes]` section with an empty crates array
// is caught here too, before scaffold_node_crate writes a crate that
// append_nodes_pointer would then refuse to attach.
let already_attached = !toml.nodes.crates.is_empty()
|| std::fs::read_to_string(root.join("Aura.toml")).is_ok_and(|t| t.contains("[nodes]"));
if already_attached {
eprintln!("aura: a node crate is already attached (multi-crate loading is not yet supported)");
std::process::exit(1);
}
let parent = root.parent().unwrap_or(&root).to_path_buf();
let spec = match scaffold::scaffold_spec(
&a.name,
a.engine_path.as_deref(),
a.namespace.as_deref(),
&parent,
) {
Ok(s) => s,
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(2);
}
};
if let Err(m) = scaffold::scaffold_node_crate(&spec) {
eprintln!("aura: {m}");
std::process::exit(1);
}
if let Err(m) = scaffold::append_nodes_pointer(&root, &format!("../{}", a.name)) {
eprintln!("aura: {m}");
std::process::exit(1);
}
println!(
"created node crate \"../{}\" (namespace \"{}\") and attached it to {}",
a.name,
spec.namespace,
root.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()
);
}
/// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the
/// first-positional is an existing `.json`, else the built-in `--strategy` grid sweep.
fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
@@ -3701,9 +3791,9 @@ fn main() {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let cli = Cli::parse();
// `aura new` scaffolds; it must not require a loadable project even
// when invoked inside one (e.g. an unbuilt tree).
let env = if matches!(cli.command, Command::New(_)) {
// `aura new`/`aura nodes new` scaffold; they must not require a
// loadable project even when invoked inside one (e.g. an unbuilt tree).
let env = if matches!(cli.command, Command::New(_) | Command::Nodes(_)) {
project::Env::std()
} else {
match std::env::current_dir()
@@ -3731,6 +3821,7 @@ fn main() {
Command::Runs(a) => dispatch_runs(a, &env),
Command::Reproduce(a) => dispatch_reproduce(a, &env),
Command::New(a) => dispatch_new(a, &env),
Command::Nodes(a) => dispatch_nodes(a),
Command::Process(a) => research_docs::process_cmd(a, &env),
Command::Campaign(a) => research_docs::campaign_cmd(a, &env),
}