Refactor CLI, add refresh command, fix port detection, add device tracking

- Remove build/upload/monitor subcommands (projects are self-contained)
- Remove ctrlc dependency (only used by removed monitor watch mode)
- Update next-steps messaging to reference project scripts directly

- Add 'anvil refresh [DIR] [--force]' to update project scripts
  to latest templates without touching user code

- Fix Windows port detection: replace fragile findstr/batch TOML
  parsing with proper comment-skipping logic; add _detect_port.ps1
  helper for reliable JSON-based port detection via PowerShell

- Add .anvil.local for machine-specific config (gitignored)
  - 'anvil devices --set [PORT] [-d DIR]' saves port + VID:PID
  - 'anvil devices --get [-d DIR]' shows saved port status
  - VID:PID tracks USB devices across COM port reassignment
  - Port resolution: -p flag > VID:PID > saved port > auto-detect
  - Uppercase normalization for Windows COM port names

- Update all .bat/.sh templates to read from .anvil.local
- Remove port entries from .anvil.toml (no machine-specific config in git)
- Add .anvil.local to .gitignore template
- Expand 'anvil devices' output with VID:PID, serial number, and
  usage instructions
This commit is contained in:
Eric Ratliff
2026-02-16 08:29:33 -06:00
parent 3298844399
commit 8fe1ef0e27
25 changed files with 2551 additions and 731 deletions

191
src/commands/refresh.rs Normal file
View File

@@ -0,0 +1,191 @@
use anyhow::{Result, Context};
use colored::*;
use std::path::PathBuf;
use std::fs;
use crate::project::config::ProjectConfig;
use crate::templates::{TemplateManager, TemplateContext};
use crate::version::ANVIL_VERSION;
/// Files that anvil owns and can safely refresh.
/// These are build/deploy infrastructure -- not user source code.
const REFRESHABLE_FILES: &[&str] = &[
"build.sh",
"build.bat",
"upload.sh",
"upload.bat",
"monitor.sh",
"monitor.bat",
"_detect_port.ps1",
"test/run_tests.sh",
"test/run_tests.bat",
];
pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> {
// Resolve project directory
let project_path = match project_dir {
Some(dir) => PathBuf::from(dir),
None => std::env::current_dir()
.context("Could not determine current directory")?,
};
let project_root = ProjectConfig::find_project_root(&project_path)?;
let config = ProjectConfig::load(&project_root)?;
println!(
"Refreshing project: {}",
config.project.name.bright_white().bold()
);
println!(
"Project directory: {}",
project_root.display().to_string().bright_black()
);
println!();
// Generate fresh copies of all refreshable files from the template
let template_name = "basic";
let context = TemplateContext {
project_name: config.project.name.clone(),
anvil_version: ANVIL_VERSION.to_string(),
};
// Extract template into a temp directory so we can compare
let temp_dir = tempfile::tempdir()
.context("Failed to create temp directory")?;
TemplateManager::extract(template_name, temp_dir.path(), &context)?;
// Compare each refreshable file
let mut up_to_date = Vec::new();
let mut will_create = Vec::new();
let mut has_changes = Vec::new();
for &filename in REFRESHABLE_FILES {
let existing = project_root.join(filename);
let fresh = temp_dir.path().join(filename);
if !fresh.exists() {
// Template doesn't produce this file (shouldn't happen)
continue;
}
let fresh_content = fs::read(&fresh)
.context(format!("Failed to read template file: {}", filename))?;
if !existing.exists() {
will_create.push(filename);
continue;
}
let existing_content = fs::read(&existing)
.context(format!("Failed to read project file: {}", filename))?;
if existing_content == fresh_content {
up_to_date.push(filename);
} else {
has_changes.push(filename);
}
}
// Report status
if !up_to_date.is_empty() {
println!(
"{} {} file(s) already up to date",
"ok".green(),
up_to_date.len()
);
}
if !will_create.is_empty() {
for f in &will_create {
println!(" {} {} (new)", "+".bright_green(), f.bright_white());
}
}
if !has_changes.is_empty() {
for f in &has_changes {
println!(
" {} {} (differs from latest)",
"~".bright_yellow(),
f.bright_white()
);
}
}
// Decide what to do
if has_changes.is_empty() && will_create.is_empty() {
println!();
println!(
"{}",
"All scripts are up to date. Nothing to do."
.bright_green()
.bold()
);
return Ok(());
}
if !has_changes.is_empty() && !force {
println!();
println!(
"{} {} script(s) differ from the latest Anvil templates.",
"!".bright_yellow(),
has_changes.len()
);
println!(
"This is normal after upgrading Anvil. To update them, run:"
);
println!();
println!(" {}", "anvil refresh --force".bright_cyan());
println!();
println!(
" {}",
"Only build scripts are replaced. Your .anvil.toml and source code are never touched."
.bright_black()
);
return Ok(());
}
// Apply updates
let files_to_write: Vec<&str> = if force {
will_create.iter().chain(has_changes.iter()).copied().collect()
} else {
will_create.to_vec()
};
for filename in &files_to_write {
let fresh = temp_dir.path().join(filename);
let dest = project_root.join(filename);
// Ensure parent directory exists
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&fresh, &dest)
.context(format!("Failed to write: {}", filename))?;
}
// Make shell scripts executable on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for filename in &files_to_write {
if filename.ends_with(".sh") {
let path = project_root.join(filename);
if let Ok(meta) = fs::metadata(&path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(&path, perms);
}
}
}
}
println!();
println!(
"{} Updated {} file(s).",
"ok".green(),
files_to_write.len()
);
Ok(())
}