diff --git a/.claude/settings.json b/.claude/settings.json index 0a4eaef..64de6ec 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,15 @@ { "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "nu /Users/joe/dev/notfiles/scripts/preflight.nu" + } + ] + } + ], "PostToolUse": [ { "matcher": "Edit|Write", diff --git a/.claude/worktrees/agent-a0b9834e b/.claude/worktrees/agent-a0b9834e new file mode 160000 index 0000000..11d329c --- /dev/null +++ b/.claude/worktrees/agent-a0b9834e @@ -0,0 +1 @@ +Subproject commit 11d329c5a2a2df0c785dfcf3025fc52828923ff0 diff --git a/.claude/worktrees/agent-a27fe5f2 b/.claude/worktrees/agent-a27fe5f2 new file mode 160000 index 0000000..11d329c --- /dev/null +++ b/.claude/worktrees/agent-a27fe5f2 @@ -0,0 +1 @@ +Subproject commit 11d329c5a2a2df0c785dfcf3025fc52828923ff0 diff --git a/.claude/worktrees/agent-a6b81d3b b/.claude/worktrees/agent-a6b81d3b new file mode 160000 index 0000000..11d329c --- /dev/null +++ b/.claude/worktrees/agent-a6b81d3b @@ -0,0 +1 @@ +Subproject commit 11d329c5a2a2df0c785dfcf3025fc52828923ff0 diff --git a/.claude/worktrees/agent-af9e1ed8 b/.claude/worktrees/agent-af9e1ed8 new file mode 160000 index 0000000..11d329c --- /dev/null +++ b/.claude/worktrees/agent-af9e1ed8 @@ -0,0 +1 @@ +Subproject commit 11d329c5a2a2df0c785dfcf3025fc52828923ff0 diff --git a/crates/notfiles/src/linker.rs b/crates/notfiles/src/linker.rs index a08b6d1..2dc68ae 100644 --- a/crates/notfiles/src/linker.rs +++ b/crates/notfiles/src/linker.rs @@ -95,7 +95,7 @@ pub fn link_package( let target = target_base.join(relative); let source_display = format!("{package}/{}", relative.display()); - // Check if already correctly linked + // Check if already correctly linked / copied if is_already_linked(&source, &target, method) { if opts.verbose { println!(" \x1b[90mskip\x1b[0m {source_display} (already linked)"); @@ -103,15 +103,26 @@ pub fn link_package( continue; } + // Helper: save partial state then return an error. + let save_and_return = |state: &mut State, e: NotfilesError| -> Result<(), NotfilesError> { + if !opts.dry_run { + let _ = state.save(dotfiles_dir); + } + Err(e) + }; + // Conflict detection if target.exists() || target.symlink_metadata().is_ok() { if !opts.force { - return Err(NotfilesError::Conflict { - path: target.clone(), - reason: format!( - "already exists (use --force to overwrite); source: {source_display}" - ), - }); + return save_and_return( + state, + NotfilesError::Conflict { + path: target.clone(), + reason: format!( + "already exists (use --force to overwrite); source: {source_display}" + ), + }, + ); } // Force mode: backup then remove if !opts.no_backup { @@ -130,13 +141,18 @@ pub fn link_package( backup.display() ); } - fs::rename(&target, &backup)?; + if let Err(e) = fs::rename(&target, &backup) { + return save_and_return(state, e.into()); + } } } else if !opts.dry_run { - if target.is_dir() { - fs::remove_dir_all(&target)?; + let rm_result = if target.is_dir() { + fs::remove_dir_all(&target) } else { - fs::remove_file(&target)?; + fs::remove_file(&target) + }; + if let Err(e) = rm_result { + return save_and_return(state, e.into()); } } } @@ -147,8 +163,8 @@ pub fn link_package( if opts.verbose { println!(" \x1b[90mwould create dir\x1b[0m {}", parent.display()); } - } else { - fs::create_dir_all(parent)?; + } else if let Err(e) = fs::create_dir_all(parent) { + return save_and_return(state, e.into()); } } @@ -164,16 +180,21 @@ pub fn link_package( target.display() ); } else { - match method { + let link_result = match method { Method::Symlink => { #[cfg(unix)] - std::os::unix::fs::symlink(&source, &target)?; + { + std::os::unix::fs::symlink(&source, &target).map(|_| 0u64) + } #[cfg(not(unix))] - fs::copy(&source, &target)?; - } - Method::Copy => { - fs::copy(&source, &target)?; + { + fs::copy(&source, &target) + } } + Method::Copy => fs::copy(&source, &target), + }; + if let Err(e) = link_result { + return save_and_return(state, e.into()); } if opts.verbose { println!( @@ -283,7 +304,15 @@ fn is_already_linked(source: &Path, target: &Path, method: Method) -> bool { false } } - Method::Copy => false, // Always re-copy + Method::Copy => { + // Skip re-copy if target exists and has the same size and mtime as source. + match (fs::metadata(source), fs::metadata(target)) { + (Ok(sm), Ok(tm)) => { + sm.len() == tm.len() && sm.modified().ok() == tm.modified().ok() + } + _ => false, + } + } } } diff --git a/crates/notgraph/src/emit.rs b/crates/notgraph/src/emit.rs index 75bc88d..3ec0446 100644 --- a/crates/notgraph/src/emit.rs +++ b/crates/notgraph/src/emit.rs @@ -172,19 +172,27 @@ fn write_html( .map(|fs| fs.name.as_str()) .collect(); - let mut leaves: Vec<&str> = crate_graph.nodes.iter() + let mut leaves: Vec<&str> = crate_graph + .nodes + .iter() .map(|n| n.as_str()) .filter(|n| leaf_nodes.contains(n) && !root_nodes.contains(n)) .collect(); - let mut roots: Vec<&str> = crate_graph.nodes.iter() + let mut roots: Vec<&str> = crate_graph + .nodes + .iter() .map(|n| n.as_str()) .filter(|n| root_nodes.contains(n) && !leaf_nodes.contains(n)) .collect(); - let mut features: Vec<&str> = crate_graph.nodes.iter() + let mut features: Vec<&str> = crate_graph + .nodes + .iter() .map(|n| n.as_str()) .filter(|n| !leaf_nodes.contains(n) && !root_nodes.contains(n)) .collect(); - let mut isolated: Vec<&str> = crate_graph.nodes.iter() + let mut isolated: Vec<&str> = crate_graph + .nodes + .iter() .map(|n| n.as_str()) .filter(|n| leaf_nodes.contains(n) && root_nodes.contains(n)) .collect(); @@ -202,7 +210,11 @@ fn write_html( let syms = sym_count.get(n).copied().unwrap_or(0); format!( "{}[\"{}
in:{} out:{} | {} pub\"]", - mermaid_id(n), n, fi, fo, syms + mermaid_id(n), + n, + fi, + fo, + syms ) }; @@ -211,17 +223,23 @@ fn write_html( if !leaves.is_empty() { mermaid.push_str(" subgraph Core\n direction TB\n"); - for n in &leaves { mermaid.push_str(&format!(" {}\n", node_label(n))); } + for n in &leaves { + mermaid.push_str(&format!(" {}\n", node_label(n))); + } mermaid.push_str(" end\n"); } if !features.is_empty() { mermaid.push_str(" subgraph Features\n direction TB\n"); - for n in &features { mermaid.push_str(&format!(" {}\n", node_label(n))); } + for n in &features { + mermaid.push_str(&format!(" {}\n", node_label(n))); + } mermaid.push_str(" end\n"); } if !roots.is_empty() { mermaid.push_str(" subgraph Tools\n direction TB\n"); - for n in &roots { mermaid.push_str(&format!(" {}\n", node_label(n))); } + for n in &roots { + mermaid.push_str(&format!(" {}\n", node_label(n))); + } mermaid.push_str(" end\n"); } @@ -234,7 +252,8 @@ fn write_html( let color = heatmap_color(fs.fan_in); mermaid.push_str(&format!( " style {} fill:{},stroke:#4a9eff,color:#e0e0e0\n", - mermaid_id(&fs.name), color + mermaid_id(&fs.name), + color )); } @@ -316,10 +335,12 @@ fn write_html( let hotspot_rows: String = stats .hotspots .iter() - .map(|h| format!( - "{}{}{}", - h.name, h.kind, h.score - )) + .map(|h| { + format!( + "{}{}{}", + h.name, h.kind, h.score + ) + }) .collect::>() .join("\n"); diff --git a/crates/notgraph/src/main.rs b/crates/notgraph/src/main.rs index a47472a..5ba3b49 100644 --- a/crates/notgraph/src/main.rs +++ b/crates/notgraph/src/main.rs @@ -63,8 +63,14 @@ fn main() -> Result<()> { workspace_root.join(&cli.output) }; - emit::write_all(&output_dir, &crate_g, &module_graphs, &stats, &symbol_tables) - .context("failed to write reports")?; + emit::write_all( + &output_dir, + &crate_g, + &module_graphs, + &stats, + &symbol_tables, + ) + .context("failed to write reports")?; println!("notgraph: reports written to {}", output_dir.display()); diff --git a/crates/notsecrets/src/sources/bitwarden.rs b/crates/notsecrets/src/sources/bitwarden.rs index 7006e96..ec6f4e9 100644 --- a/crates/notsecrets/src/sources/bitwarden.rs +++ b/crates/notsecrets/src/sources/bitwarden.rs @@ -50,15 +50,19 @@ impl BitwardenSource { source: anyhow::anyhow!("bw unlock spawn: {e}"), })?; if let Some(mut stdin) = child.stdin.take() { - stdin.write_all(password.as_bytes()).map_err(|e| AgeError::SourceError { - name: self.name().to_string(), - source: anyhow::anyhow!("bw unlock stdin write: {e}"), - })?; + stdin + .write_all(password.as_bytes()) + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("bw unlock stdin write: {e}"), + })?; } - let output = child.wait_with_output().map_err(|e| AgeError::SourceError { - name: self.name().to_string(), - source: anyhow::anyhow!("bw unlock wait: {e}"), - })?; + let output = child + .wait_with_output() + .map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("bw unlock wait: {e}"), + })?; if !output.status.success() { return Err(AgeError::SourceError { name: self.name().to_string(), diff --git a/crates/notstrap/src/lib.rs b/crates/notstrap/src/lib.rs index 65df8c7..42c2f63 100644 --- a/crates/notstrap/src/lib.rs +++ b/crates/notstrap/src/lib.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use notcore::{HookPhase, Report, StepStatus}; use notfiles::{LinkOptions, link}; use nothooks::{HookRunner, run_phase}; +use notsecrets::identities::Identity; use notsecrets::sources::IdentitySource; use notsecrets::{BitwardenSource, FileSource, PromptSource, resolve_identities}; use serde::Deserialize; @@ -10,7 +11,7 @@ use std::path::{Path, PathBuf}; pub mod prereqs; pub mod repo; -type EnvInjector = Box Result>; +type EnvInjector = Box>) -> Result>; #[derive(Deserialize)] pub struct NotstrapConfig { diff --git a/scripts/preflight.nu b/scripts/preflight.nu new file mode 100644 index 0000000..f184fae --- /dev/null +++ b/scripts/preflight.nu @@ -0,0 +1,37 @@ +#!/usr/bin/env nu +# preflight.nu — notfiles environment validation + +def check [label: string, pass: bool, detail: string = ""] { + if $pass { + print $"[ok] ($label)" + } else if $detail != "" { + print $"[fail] ($label) — ($detail)" + } else { + print $"[fail] ($label)" + } + $pass +} + +print "=== notfiles preflight ===" + +let results = [ + (check "cargo on PATH" (which cargo | length) > 0), + (check "just on PATH" (which just | length) > 0), + (check "nu on PATH" (which nu | length) > 0 "nushell required for hook scripts"), + (check "age on PATH" (which age | length) > 0 "optional — needed for SOPS decryption"), + (check "sops on PATH" (which sops | length) > 0 "optional — needed for encrypted configs"), + (check "notfiles on PATH" (which notfiles | length) > 0 "run: cargo install --path crates/notfiles"), + (check "op on PATH" (which op | length) > 0), + (check "1Password authed" (do { op account list } | complete | get exit_code) == 0), + (check "git repo clean" (do { git status --porcelain } | complete | get stdout | str trim | is-empty)), +] + +let failed = $results | where { |r| not $r } | length +let total = $results | length + +print "" +if $failed == 0 { + print $"preflight passed ($total)/($total)" +} else { + print $"preflight ($total - $failed)/($total) — ($failed) check(s) failed" +}