docs: update HANDOFF.md — age-native redesign progress (tasks 1–7 complete)
This commit is contained in:
@@ -77,10 +77,12 @@ impl Config {
|
||||
if !config_path.exists() {
|
||||
return Ok(Config::default());
|
||||
}
|
||||
let content = std::fs::read_to_string(&config_path)
|
||||
.map_err(|e| NotfilesError::Config(format!("reading {}: {e}", config_path.display())))?;
|
||||
let config: Config = toml::from_str(&content)
|
||||
.map_err(|e| NotfilesError::Config(format!("parsing {}: {e}", config_path.display())))?;
|
||||
let content = std::fs::read_to_string(&config_path).map_err(|e| {
|
||||
NotfilesError::Config(format!("reading {}: {e}", config_path.display()))
|
||||
})?;
|
||||
let config: Config = toml::from_str(&content).map_err(|e| {
|
||||
NotfilesError::Config(format!("parsing {}: {e}", config_path.display()))
|
||||
})?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,10 @@ pub struct Report {
|
||||
|
||||
impl Report {
|
||||
pub fn add(&mut self, name: impl Into<String>, status: StepStatus) {
|
||||
self.steps.push(Step { name: name.into(), status });
|
||||
self.steps.push(Step {
|
||||
name: name.into(),
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn print(&self) {
|
||||
@@ -67,7 +70,9 @@ impl Report {
|
||||
}
|
||||
|
||||
pub fn has_failures(&self) -> bool {
|
||||
self.steps.iter().any(|s| matches!(s.status, StepStatus::Failed(_)))
|
||||
self.steps
|
||||
.iter()
|
||||
.any(|s| matches!(s.status, StepStatus::Failed(_)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ impl IgnoreMatcher {
|
||||
// Match the pattern as a filename component and also as a path suffix.
|
||||
let glob = Glob::new(pattern)
|
||||
.or_else(|_| Glob::new(&format!("**/{pattern}")))
|
||||
.map_err(|e| NotfilesError::Other(format!("invalid ignore pattern '{pattern}': {e}")))?;
|
||||
.map_err(|e| {
|
||||
NotfilesError::Other(format!("invalid ignore pattern '{pattern}': {e}"))
|
||||
})?;
|
||||
builder.add(glob);
|
||||
// Also add a recursive variant so "foo" matches "a/foo" etc.
|
||||
#[allow(clippy::collapsible_if)]
|
||||
|
||||
@@ -10,11 +10,7 @@ use std::path::Path;
|
||||
pub use linker::{LinkOptions, State};
|
||||
pub use package::resolve_packages;
|
||||
|
||||
pub fn link(
|
||||
dotfiles_dir: &Path,
|
||||
packages: &[String],
|
||||
opts: &LinkOptions,
|
||||
) -> Result<State> {
|
||||
pub fn link(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<State> {
|
||||
let config = notcore::Config::load(dotfiles_dir)?;
|
||||
let mut state = State::load(dotfiles_dir)?;
|
||||
let pkgs = resolve_packages(dotfiles_dir, packages)?;
|
||||
|
||||
@@ -4,8 +4,8 @@ use std::path::{Path, PathBuf};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use notcore::{Config, Method, NotfilesError, expand_tilde};
|
||||
use crate::package::collect_files;
|
||||
use notcore::{Config, Method, NotfilesError, expand_tilde};
|
||||
|
||||
const STATE_FILE: &str = ".notfiles-state.toml";
|
||||
|
||||
@@ -46,7 +46,10 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn entries_for_package(&self, package: &str) -> Vec<&StateEntry> {
|
||||
self.entries.iter().filter(|e| e.package == package).collect()
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|e| e.package == package)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn remove_package(&mut self, package: &str) {
|
||||
@@ -55,7 +58,8 @@ impl State {
|
||||
|
||||
pub fn add_entry(&mut self, entry: StateEntry) {
|
||||
// Remove existing entry for same source+target, then add new
|
||||
self.entries.retain(|e| !(e.source == entry.source && e.target == entry.target));
|
||||
self.entries
|
||||
.retain(|e| !(e.source == entry.source && e.target == entry.target));
|
||||
self.entries.push(entry);
|
||||
}
|
||||
}
|
||||
@@ -113,10 +117,18 @@ pub fn link_package(
|
||||
if !opts.no_backup {
|
||||
let backup = backup_path(&target);
|
||||
if opts.dry_run {
|
||||
println!(" \x1b[33mwould backup\x1b[0m {} -> {}", target.display(), backup.display());
|
||||
println!(
|
||||
" \x1b[33mwould backup\x1b[0m {} -> {}",
|
||||
target.display(),
|
||||
backup.display()
|
||||
);
|
||||
} else {
|
||||
if opts.verbose {
|
||||
println!(" \x1b[33mbackup\x1b[0m {} -> {}", target.display(), backup.display());
|
||||
println!(
|
||||
" \x1b[33mbackup\x1b[0m {} -> {}",
|
||||
target.display(),
|
||||
backup.display()
|
||||
);
|
||||
}
|
||||
fs::rename(&target, &backup)?;
|
||||
}
|
||||
@@ -147,7 +159,10 @@ pub fn link_package(
|
||||
};
|
||||
|
||||
if opts.dry_run {
|
||||
println!(" \x1b[36mwould {action_word}\x1b[0m {source_display} -> {}", target.display());
|
||||
println!(
|
||||
" \x1b[36mwould {action_word}\x1b[0m {source_display} -> {}",
|
||||
target.display()
|
||||
);
|
||||
} else {
|
||||
match method {
|
||||
Method::Symlink => {
|
||||
@@ -161,7 +176,10 @@ pub fn link_package(
|
||||
}
|
||||
}
|
||||
if opts.verbose {
|
||||
println!(" \x1b[32m{action_word}\x1b[0m {source_display} -> {}", target.display());
|
||||
println!(
|
||||
" \x1b[32m{action_word}\x1b[0m {source_display} -> {}",
|
||||
target.display()
|
||||
);
|
||||
}
|
||||
|
||||
state.add_entry(StateEntry {
|
||||
@@ -183,7 +201,11 @@ pub fn unlink_package(
|
||||
package: &str,
|
||||
opts: &LinkOptions,
|
||||
) -> Result<(), NotfilesError> {
|
||||
let entries: Vec<StateEntry> = state.entries_for_package(package).into_iter().cloned().collect();
|
||||
let entries: Vec<StateEntry> = state
|
||||
.entries_for_package(package)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if entries.is_empty() {
|
||||
if opts.verbose {
|
||||
@@ -278,7 +300,10 @@ fn cleanup_empty_parents(path: &Path) {
|
||||
if Some(parent.to_path_buf()) == dirs::home_dir() || parent == Path::new("/") {
|
||||
break;
|
||||
}
|
||||
if fs::read_dir(parent).map(|mut d| d.next().is_none()).unwrap_or(false) {
|
||||
if fs::read_dir(parent)
|
||||
.map(|mut d| d.next().is_none())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let _ = fs::remove_dir(parent);
|
||||
dir = parent.parent();
|
||||
} else {
|
||||
|
||||
@@ -2,11 +2,11 @@ use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use std::fs;
|
||||
|
||||
use notcore::Config;
|
||||
use notfiles::cli::{Cli, Command};
|
||||
use notfiles::linker::{LinkOptions, State};
|
||||
use notfiles::package::resolve_packages;
|
||||
use notfiles::{linker, status};
|
||||
use notcore::Config;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
@@ -46,7 +46,10 @@ fn main() -> Result<()> {
|
||||
|
||||
if !cli.dry_run {
|
||||
state.save(&dotfiles_dir)?;
|
||||
let count: usize = pkgs.iter().map(|p| state.entries_for_package(p).len()).sum();
|
||||
let count: usize = pkgs
|
||||
.iter()
|
||||
.map(|p| state.entries_for_package(p).len())
|
||||
.sum();
|
||||
println!(
|
||||
"\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m",
|
||||
if count == 1 { "" } else { "s" },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use notcore::{Config, NotfilesError};
|
||||
use crate::ignore::IgnoreMatcher;
|
||||
use notcore::{Config, NotfilesError};
|
||||
|
||||
/// Discover available packages (subdirectories of the dotfiles dir).
|
||||
pub fn discover_packages(dotfiles_dir: &Path) -> Result<Vec<String>, NotfilesError> {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use notcore::{Config, Method, expand_tilde};
|
||||
use crate::linker::State;
|
||||
use crate::package::collect_files;
|
||||
use notcore::{Config, Method, expand_tilde};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum FileStatus {
|
||||
@@ -69,8 +69,7 @@ pub fn package_status(
|
||||
}
|
||||
Method::Copy => {
|
||||
let has_state = state.entries.iter().any(|e| {
|
||||
e.package == package
|
||||
&& e.target == target.to_string_lossy().as_ref()
|
||||
e.package == package && e.target == target.to_string_lossy().as_ref()
|
||||
});
|
||||
if has_state && target.exists() {
|
||||
FileStatus::Copied
|
||||
@@ -122,6 +121,11 @@ pub fn print_status(package: &str, entries: &[StatusEntry]) {
|
||||
}
|
||||
println!(" \x1b[1m{package}\x1b[0m:");
|
||||
for entry in entries {
|
||||
println!(" {} {} -> {}", entry.status, entry.source_display, entry.target.display());
|
||||
println!(
|
||||
" {} {} -> {}",
|
||||
entry.status,
|
||||
entry.source_display,
|
||||
entry.target.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,13 @@ fn test_link_and_status() {
|
||||
|
||||
let gitconfig = target.join(".gitconfig");
|
||||
assert!(gitconfig.exists());
|
||||
assert!(gitconfig.symlink_metadata().unwrap().file_type().is_symlink());
|
||||
assert!(
|
||||
gitconfig
|
||||
.symlink_metadata()
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
);
|
||||
|
||||
// State file should exist
|
||||
assert!(dotfiles.join(".notfiles-state.toml").exists());
|
||||
@@ -199,7 +205,14 @@ fn test_force_with_backup() {
|
||||
assert!(ok);
|
||||
|
||||
// The link should now exist
|
||||
assert!(target.join(".gitconfig").symlink_metadata().unwrap().file_type().is_symlink());
|
||||
assert!(
|
||||
target
|
||||
.join(".gitconfig")
|
||||
.symlink_metadata()
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
);
|
||||
|
||||
// A backup should exist
|
||||
let backups: Vec<_> = fs::read_dir(&target)
|
||||
@@ -232,11 +245,7 @@ fn test_force_no_backup() {
|
||||
let backups: Vec<_> = fs::read_dir(&target)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_string_lossy()
|
||||
.contains("notfiles-backup")
|
||||
})
|
||||
.filter(|e| e.file_name().to_string_lossy().contains("notfiles-backup"))
|
||||
.collect();
|
||||
assert_eq!(backups.len(), 0);
|
||||
}
|
||||
@@ -272,8 +281,17 @@ method = "copy"
|
||||
let ssh_config = target.join(".ssh/config");
|
||||
assert!(ssh_config.exists());
|
||||
// Should NOT be a symlink
|
||||
assert!(!ssh_config.symlink_metadata().unwrap().file_type().is_symlink());
|
||||
assert_eq!(fs::read_to_string(&ssh_config).unwrap(), "Host *\n AddKeysToAgent yes");
|
||||
assert!(
|
||||
!ssh_config
|
||||
.symlink_metadata()
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&ssh_config).unwrap(),
|
||||
"Host *\n AddKeysToAgent yes"
|
||||
);
|
||||
|
||||
// Status should show "copied"
|
||||
let (stdout, _, _) = run(&dotfiles, &["status", "ssh"]);
|
||||
|
||||
121
crates/nothooks/README.md
Normal file
121
crates/nothooks/README.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# nothooks
|
||||
|
||||
Nushell hook runner for the notfiles workspace. Executes `.nu` scripts in two
|
||||
distinct lifecycle phases — **dot** (always) and **setup** (once) — with
|
||||
state persistence so setup hooks are not re-run across invocations.
|
||||
|
||||
## Concepts
|
||||
|
||||
### HookPhase
|
||||
|
||||
Each hook belongs to exactly one phase:
|
||||
|
||||
| Phase | When it runs | Typical use |
|
||||
|-------|-------------|-------------|
|
||||
| `dot` | Every time nothooks is invoked | Apply shell config, set env vars, refresh symlinks |
|
||||
| `setup` | Once per machine (tracked in state) | Install packages, create dirs, first-time configuration |
|
||||
|
||||
### HookSpec
|
||||
|
||||
A hook is described by three fields (defined in `notcore`):
|
||||
|
||||
```toml
|
||||
[[hooks]]
|
||||
name = "install-starship"
|
||||
script = "hooks/setup/install-starship.nu"
|
||||
phase = "setup"
|
||||
|
||||
[[hooks]]
|
||||
name = "set-default-shell"
|
||||
script = "hooks/dot/shell.nu"
|
||||
phase = "dot"
|
||||
```
|
||||
|
||||
### State tracking
|
||||
|
||||
Setup hooks are tracked in `.nothooks-state.toml` in the state directory
|
||||
(typically the dotfiles root). Once a setup hook completes successfully its
|
||||
name is recorded and it will be **skipped** on all future runs.
|
||||
|
||||
```toml
|
||||
# .nothooks-state.toml (auto-generated, do not edit manually)
|
||||
completed_setup_hooks = ["install-starship", "configure-git"]
|
||||
```
|
||||
|
||||
Dot hooks are **never** recorded in state — they run unconditionally on every
|
||||
invocation.
|
||||
|
||||
### --force re-run
|
||||
|
||||
To re-run setup hooks (e.g. after a machine rebuild or to apply changes),
|
||||
construct the runner with `HookRunner::with_force`. This bypasses the state
|
||||
check so all setup hooks execute regardless of prior completion.
|
||||
|
||||
The notstrap CLI exposes this as `--force`.
|
||||
|
||||
## Hook script lifecycle
|
||||
|
||||
```
|
||||
notstrap / nothooks invoked
|
||||
│
|
||||
├── dot phase
|
||||
│ └── for each hook where phase == "dot"
|
||||
│ └── nu <script> # always runs
|
||||
│
|
||||
└── setup phase
|
||||
└── for each hook where phase == "setup"
|
||||
├── state.is_done(name)?
|
||||
│ ├── yes (and !force) → skip
|
||||
│ └── no (or force) → nu <script>
|
||||
│ └── success → state.mark_done(name)
|
||||
└── report collected
|
||||
```
|
||||
|
||||
## Writing a hook script
|
||||
|
||||
Hook scripts are plain Nushell (`.nu`) files. They receive no arguments and
|
||||
communicate success/failure via exit code (0 = success, non-zero = failure).
|
||||
|
||||
```nushell
|
||||
#!/usr/bin/env nu
|
||||
# hooks/setup/install-starship.nu
|
||||
# Installs starship prompt if not already present.
|
||||
|
||||
if (which starship | is-empty) {
|
||||
print "Installing starship..."
|
||||
sh -c "curl -sS https://starship.rs/install.sh | sh -s -- -y"
|
||||
} else {
|
||||
print "starship already installed, skipping"
|
||||
}
|
||||
```
|
||||
|
||||
```nushell
|
||||
#!/usr/bin/env nu
|
||||
# hooks/dot/xdg-dirs.nu
|
||||
# Ensure XDG base directories exist on every login.
|
||||
|
||||
mkdir ~/.config ~/.cache ~/.local/share ~/.local/state
|
||||
```
|
||||
|
||||
## Usage from Rust
|
||||
|
||||
```rust
|
||||
use nothooks::{HookRunner, run_phase};
|
||||
use notcore::{HookPhase, HookSpec};
|
||||
use std::path::PathBuf;
|
||||
|
||||
let hooks: Vec<HookSpec> = /* load from notfiles.toml */;
|
||||
let state_dir = PathBuf::from("/path/to/dotfiles");
|
||||
|
||||
// Normal run
|
||||
let runner = HookRunner::new(state_dir.clone());
|
||||
|
||||
// Force re-run of setup hooks
|
||||
let runner = HookRunner::with_force(state_dir.clone());
|
||||
|
||||
let dot_report = run_phase(&hooks, &HookPhase::Dot, &runner);
|
||||
let setup_report = run_phase(&hooks, &HookPhase::Setup, &runner);
|
||||
```
|
||||
|
||||
`run_phase` returns a `notcore::Report` containing a `StepStatus` (`Ok`,
|
||||
`Skipped`, or `Failed(msg)`) for each hook in that phase.
|
||||
@@ -1,8 +1,8 @@
|
||||
pub mod runner;
|
||||
pub mod state;
|
||||
|
||||
pub use runner::HookRunner;
|
||||
use notcore::{HookPhase, HookSpec, Report, StepStatus};
|
||||
pub use runner::HookRunner;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum HookResult {
|
||||
@@ -12,11 +12,7 @@ pub enum HookResult {
|
||||
}
|
||||
|
||||
/// Run all hooks matching `phase` and collect into a `Report`.
|
||||
pub fn run_phase(
|
||||
hooks: &[HookSpec],
|
||||
phase: &HookPhase,
|
||||
runner: &HookRunner,
|
||||
) -> Report {
|
||||
pub fn run_phase(hooks: &[HookSpec], phase: &HookPhase, runner: &HookRunner) -> Report {
|
||||
let mut report = Report::default();
|
||||
for hook in hooks.iter().filter(|h| &h.phase == phase) {
|
||||
let result = runner.run_hook(hook);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use nothooks::{run_phase, HookRunner};
|
||||
use notcore::{HookPhase, HookSpec};
|
||||
use nothooks::{HookRunner, run_phase};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use notcore::{HookPhase, HookSpec};
|
||||
use crate::HookResult;
|
||||
use crate::state::HookState;
|
||||
use notcore::{HookPhase, HookSpec};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
pub struct HookRunner {
|
||||
state_dir: PathBuf,
|
||||
@@ -11,11 +11,17 @@ pub struct HookRunner {
|
||||
|
||||
impl HookRunner {
|
||||
pub fn new(state_dir: PathBuf) -> Self {
|
||||
Self { state_dir, force: false }
|
||||
Self {
|
||||
state_dir,
|
||||
force: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_force(state_dir: PathBuf) -> Self {
|
||||
Self { state_dir, force: true }
|
||||
Self {
|
||||
state_dir,
|
||||
force: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_hook(&self, spec: &HookSpec) -> HookResult {
|
||||
@@ -25,9 +31,8 @@ impl HookRunner {
|
||||
return HookResult::Skipped;
|
||||
}
|
||||
|
||||
let result = Command::new("nu")
|
||||
.arg(&spec.script)
|
||||
.status();
|
||||
// TODO: support other shells
|
||||
let result = Command::new("nu").arg(&spec.script).status();
|
||||
|
||||
match result {
|
||||
Ok(status) if status.success() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
const STATE_FILE: &str = ".nothooks-state.toml";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use notcore::{HookPhase, HookSpec};
|
||||
use nothooks::{HookResult, HookRunner};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
use nothooks::{HookResult, HookRunner};
|
||||
use notcore::{HookPhase, HookSpec};
|
||||
|
||||
fn make_hook_script(dir: &TempDir, name: &str, content: &str) -> HookSpec {
|
||||
let path = dir.path().join(format!("{name}.nu"));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use anyhow::{Context, Result};
|
||||
use notcore::{HookPhase, Report, StepStatus};
|
||||
use notfiles::{link, LinkOptions};
|
||||
use nothooks::{run_phase, HookRunner};
|
||||
use notsecrets::{install_age_key, resolve_age_key, BitwardenSource, FileSource, PromptSource};
|
||||
use notfiles::{LinkOptions, link};
|
||||
use nothooks::{HookRunner, run_phase};
|
||||
use notsecrets::{BitwardenSource, FileSource, PromptSource, install_age_key, resolve_age_key};
|
||||
use serde::Deserialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -28,8 +28,12 @@ pub struct BootstrapSection {
|
||||
pub sops_file: String,
|
||||
}
|
||||
|
||||
pub(crate) fn default_bw_item() -> String { "age-key-dotfiles".to_string() }
|
||||
pub(crate) fn default_sops_file() -> String { "secrets/bootstrap.sops.env".to_string() }
|
||||
pub(crate) fn default_bw_item() -> String {
|
||||
"age-key-dotfiles".to_string()
|
||||
}
|
||||
pub(crate) fn default_sops_file() -> String {
|
||||
"secrets/bootstrap.sops.env".to_string()
|
||||
}
|
||||
|
||||
pub struct BootstrapOptions {
|
||||
pub config: PathBuf,
|
||||
@@ -48,7 +52,9 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
|
||||
// 1. Prerequisites
|
||||
if let Some(check) = opts.check_prereqs {
|
||||
match check() {
|
||||
Ok(_) => { report.add("prerequisites", StepStatus::Ok); }
|
||||
Ok(_) => {
|
||||
report.add("prerequisites", StepStatus::Ok);
|
||||
}
|
||||
Err(e) => {
|
||||
report.add("prerequisites", StepStatus::Failed(e.to_string()));
|
||||
return Ok(report);
|
||||
@@ -63,15 +69,20 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
|
||||
|
||||
let dotfiles_dir = match opts.dotfiles {
|
||||
Some(d) => d,
|
||||
None => notcore::expand_tilde(&cfg.bootstrap.dotfiles_dir)
|
||||
.with_context(|| format!("cannot expand dotfiles_dir: {}", cfg.bootstrap.dotfiles_dir))?,
|
||||
None => notcore::expand_tilde(&cfg.bootstrap.dotfiles_dir).with_context(|| {
|
||||
format!("cannot expand dotfiles_dir: {}", cfg.bootstrap.dotfiles_dir)
|
||||
})?,
|
||||
};
|
||||
|
||||
// 3. Clone dotfiles if missing
|
||||
match repo::clone_if_missing(&cfg.bootstrap.dotfiles_repo, &dotfiles_dir) {
|
||||
Ok(true) => { report.add("clone dotfiles", StepStatus::Ok); }
|
||||
Ok(false) => { report.add("clone dotfiles", StepStatus::Skipped); }
|
||||
Err(e) => {
|
||||
Ok(true) => {
|
||||
report.add("clone dotfiles", StepStatus::Ok);
|
||||
}
|
||||
Ok(false) => {
|
||||
report.add("clone dotfiles", StepStatus::Skipped);
|
||||
}
|
||||
Err(e) => {
|
||||
report.add("clone dotfiles", StepStatus::Failed(e.to_string()));
|
||||
return Ok(report);
|
||||
}
|
||||
@@ -110,7 +121,9 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
|
||||
// threads exist. Subsequent Command::spawn calls (git, hooks) will
|
||||
// inherit these vars — keys are validated by parse_env_line before
|
||||
// reaching this point (null bytes and dangerous keys are rejected).
|
||||
unsafe { std::env::set_var(&k, &v); }
|
||||
unsafe {
|
||||
std::env::set_var(&k, &v);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
@@ -129,7 +142,12 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
|
||||
}
|
||||
|
||||
// 6. Link dotfiles
|
||||
let link_opts = LinkOptions { force: opts.force, no_backup: false, dry_run: false, verbose: false };
|
||||
let link_opts = LinkOptions {
|
||||
force: opts.force,
|
||||
no_backup: false,
|
||||
dry_run: false,
|
||||
verbose: false,
|
||||
};
|
||||
match link(&dotfiles_dir, &[], &link_opts) {
|
||||
Ok(state) => {
|
||||
let count = state.entries.len();
|
||||
@@ -147,9 +165,14 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
|
||||
HookRunner::new(dotfiles_dir.clone())
|
||||
};
|
||||
|
||||
for (phase, label) in [(HookPhase::Dot, "dot hooks"), (HookPhase::Setup, "setup hooks")] {
|
||||
for (phase, label) in [
|
||||
(HookPhase::Dot, "dot hooks"),
|
||||
(HookPhase::Setup, "setup hooks"),
|
||||
] {
|
||||
let phase_report = run_phase(&cfg.hooks, &phase, &runner);
|
||||
let failed = phase_report.steps.iter()
|
||||
let failed = phase_report
|
||||
.steps
|
||||
.iter()
|
||||
.filter(|s| matches!(s.status, notcore::StepStatus::Failed(_)))
|
||||
.count();
|
||||
let summary = if failed > 0 {
|
||||
@@ -223,7 +246,12 @@ mod tests {
|
||||
fn parse_env_line_blocks_ld_preload() {
|
||||
let result = parse_env_line("LD_PRELOAD=/evil/lib.so");
|
||||
assert!(result.is_err(), "expected error for LD_PRELOAD");
|
||||
assert!(result.unwrap_err().to_string().contains("sensitive env key"));
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("sensitive env key")
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #5: PATH must be blocked.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use notstrap::{prereqs, run, BootstrapOptions};
|
||||
use notstrap::{BootstrapOptions, prereqs, run};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "notstrap", about = "Bootstrap a new machine from dotfiles")]
|
||||
@@ -33,7 +33,12 @@ enum Cmd {
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let Cmd::Run { config, force, key_file, dotfiles } = cli.command;
|
||||
let Cmd::Run {
|
||||
config,
|
||||
force,
|
||||
key_file,
|
||||
dotfiles,
|
||||
} = cli.command;
|
||||
let opts = BootstrapOptions {
|
||||
config,
|
||||
force,
|
||||
|
||||
@@ -7,9 +7,18 @@ pub struct Prereq {
|
||||
}
|
||||
|
||||
const PREREQS: &[Prereq] = &[
|
||||
Prereq { cmd: "nu", install_hint: "brew install nushell OR nix-env -iA nixpkgs.nushell" },
|
||||
Prereq { cmd: "sops", install_hint: "brew install sops OR nix-env -iA nixpkgs.sops" },
|
||||
Prereq { cmd: "age", install_hint: "brew install age OR nix-env -iA nixpkgs.age" },
|
||||
Prereq {
|
||||
cmd: "nu",
|
||||
install_hint: "brew install nushell OR nix-env -iA nixpkgs.nushell",
|
||||
},
|
||||
Prereq {
|
||||
cmd: "sops",
|
||||
install_hint: "brew install sops OR nix-env -iA nixpkgs.sops",
|
||||
},
|
||||
Prereq {
|
||||
cmd: "age",
|
||||
install_hint: "brew install age OR nix-env -iA nixpkgs.age",
|
||||
},
|
||||
];
|
||||
|
||||
pub fn check_prerequisites() -> Result<()> {
|
||||
|
||||
Reference in New Issue
Block a user