docs: update HANDOFF.md — age-native redesign progress (tasks 1–7 complete)
This commit is contained in:
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"));
|
||||
|
||||
Reference in New Issue
Block a user