Skip to content

Commit bb4f689

Browse files
authored
[Prototype] Implement pre-run hooks with .hooks.d support (#15)
* Implement pre-run hooks with .hooks.d support Add pre-run hooks feature that executes scripts before main script execution. Hooks enable environment validation, dependency checking, authentication, and setup tasks. Implementation: - Hook discovery from .hooks.d directory with lexicographic ordering - Two hook types: executable (separate process) and sourced (same shell context) - Sourced hooks use .source suffix and can modify environment variables - Wrapper script generation for proper execution flow - --skip-hooks flag to bypass hook execution - Full environment variable injection (TOME_ROOT, TOME_SCRIPT_PATH, etc.) Tests: - 38 total tests covering unit, integration, and E2E scenarios - Comprehensive validation of hook discovery, execution, and environment handling - Deno E2E tests verify real-world usage with both tome-cli and wrapper Documentation: - Complete user guide in docs/hooks.md with examples and use cases - Updated README.md with hooks feature section - Example hooks in examples/.hooks.d for reference All tests passing. Feature ready for use. * Refactor hooks implementation with proper quoting and improved portability This commit improves the hooks system with better shell compatibility, proper argument escaping, and cleaner code organization. Changes: * Rename findBash() to findShell() and simplify implementation - Better name reflects that it finds bash OR sh - Remove hardcoded path checks for bash and sh - Use exec.LookPath("bash") with fallback to exec.LookPath("sh") - Use actual shell name (bash/sh) as argv[0] instead of hardcoding - More portable and idiomatic Go code * Add proper shell escaping for paths and arguments - Integrate al.essio.dev/pkg/shellescape library - Quote all hook paths, script paths in wrapper template - Quote arguments only when necessary (e.g., spaces, special chars) - Prevents command injection and handles edge cases correctly * Fix hardcoded /tmp paths in tests - Replace all hardcoded /tmp paths with t.TempDir() - Ensures proper test isolation and cross-platform compatibility - Updated: hooks_test.go, hooks_integration_test.go * Add comprehensive sh compatibility tests - New TestShellCompatibility suite with 3 test cases - Verify wrapper scripts execute correctly with sh (not just bash) - Test sourced hooks work with sh - Validate POSIX-compliant syntax (no bash-specific features) - Add executeWrapperContentWithSh() helper function * Add test for paths with spaces - Verify proper quoting of paths containing spaces - Test that simple args remain unquoted while complex args are quoted - Ensures robustness with real-world path scenarios All tests passing: - Unit tests: 9 test suites - Integration tests: including new sh compatibility tests - E2E tests: 25/25
1 parent b5d315d commit bb4f689

File tree

13 files changed

+2534
-3
lines changed

13 files changed

+2534
-3
lines changed

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,26 @@ case $1 in
176176
esac
177177
```
178178

179+
### Pre-Run Hooks
180+
181+
Execute custom scripts before your main scripts run. Perfect for:
182+
- Environment validation and setup
183+
- Dependency checking
184+
- Authentication checks
185+
- Audit logging
186+
187+
Create a `.hooks.d/` directory in your scripts root and add numbered hooks:
188+
189+
```bash
190+
# Executable hook - runs as separate process
191+
.hooks.d/00-check-deps
192+
193+
# Sourced hook - runs in same shell, can modify environment
194+
.hooks.d/05-set-env.source
195+
```
196+
197+
See [docs/hooks.md](./docs/hooks.md) for complete guide with examples.
198+
179199
### Flexible Root Detection
180200

181201
tome-cli determines the scripts root directory from multiple sources (in order of precedence):
@@ -200,10 +220,10 @@ This flexibility allows team members to customize locations without changing the
200220
- ✅ Environment variable injection
201221
- ✅ Structured logging with levels
202222
- ✅ Generated documentation
223+
- ✅ Pre-run hooks (.hooks.d folder execution)
203224

204225
### Planned
205226
- ⏳ ActiveHelp integration for contextual assistance
206-
- ⏳ Pre/post hooks (hooks.d folder execution)
207227
- ⏳ Enhanced directory help (show all subcommands in tree)
208228
- ⏳ Improved completion output filtering
209229

@@ -318,6 +338,7 @@ Make sure:
318338
- [Your First Script](#your-first-script) - Create your first script
319339
- [Writing Scripts Guide](./docs/writing-scripts.md) - Comprehensive guide to writing scripts
320340
- [Completion Guide](./docs/completion-guide.md) - Implement custom tab completions
341+
- [Pre-Run Hooks Guide](./docs/hooks.md) - Add validation and setup hooks
321342
- [Migration Guide](./docs/migration.md) - Migrate from original tome/sub
322343

323344
### Core Documentation

cmd/exec.go

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package cmd
66
import (
77
"fmt"
88
"os"
9+
"os/exec"
910
"path"
1011
"path/filepath"
1112
"strings"
@@ -80,11 +81,67 @@ func ExecRunE(cmd *cobra.Command, args []string) error {
8081
envs = append(envs, fmt.Sprintf("%s_ROOT=%s", executableAsEnvPrefix, absRootDir))
8182
envs = append(envs, fmt.Sprintf("%s_EXECUTABLE=%s", executableAsEnvPrefix, config.ExecutableName()))
8283

83-
args = append([]string{maybeFile}, maybeArgs...)
84-
execOrLog(maybeFile, args, envs)
84+
// Check for hooks and generate wrapper if needed
85+
var execTarget string
86+
var execArgs []string
87+
88+
if !skipHooks {
89+
hookRunner := NewHookRunner(config)
90+
hooks, err := hookRunner.DiscoverHooks()
91+
if err != nil {
92+
fmt.Printf("Error discovering hooks: %v\n", err)
93+
os.Exit(1)
94+
}
95+
96+
if len(hooks) > 0 {
97+
// Generate wrapper script content
98+
wrapperContent, err := hookRunner.GenerateWrapperScriptContent(hooks, executable, maybeArgs)
99+
if err != nil {
100+
fmt.Printf("Error generating wrapper script: %v\n", err)
101+
os.Exit(1)
102+
}
103+
104+
// Execute shell with inline script instead of script directly
105+
shellPath, err := findShell()
106+
if err != nil {
107+
fmt.Printf("Error finding shell: %v\n", err)
108+
os.Exit(1)
109+
}
110+
execTarget = shellPath
111+
// Use basename of shell path for argv[0]
112+
shellName := filepath.Base(shellPath)
113+
execArgs = []string{shellName, "-c", wrapperContent}
114+
} else {
115+
// No hooks, execute script directly
116+
execTarget = executable
117+
execArgs = append([]string{executable}, maybeArgs...)
118+
}
119+
} else {
120+
// Skip hooks, execute script directly
121+
execTarget = executable
122+
execArgs = append([]string{executable}, maybeArgs...)
123+
}
124+
125+
execOrLog(execTarget, execArgs, envs)
85126
return nil
86127
}
87128

129+
// findShell locates a POSIX shell, preferring bash but falling back to sh if unavailable
130+
func findShell() (string, error) {
131+
// Try bash first
132+
if bashPath, err := exec.LookPath("bash"); err == nil {
133+
return bashPath, nil
134+
}
135+
136+
// Fall back to sh (POSIX standard)
137+
if shPath, err := exec.LookPath("sh"); err == nil {
138+
log.Debugw("bash not found, using sh as fallback", "path", shPath)
139+
return shPath, nil
140+
}
141+
142+
return "", fmt.Errorf("neither bash nor sh found")
143+
}
144+
88145
func execOrLog(arv0 string, argv []string, env []string) {
89146
if dryRun {
90147
fmt.Printf("dry run:\nbinary: %s\nargs: %+v\nenv (injected):\n%+v\n", arv0, strings.Join(argv, " "), strings.Join(env, "\n"))
@@ -128,9 +185,12 @@ var execCmd = &cobra.Command{
128185
}
129186

130187
var dryRun bool
188+
var skipHooks bool
131189

132190
func init() {
133191
execCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Dry run the exec command")
192+
execCmd.Flags().BoolVar(&skipHooks, "skip-hooks", false, "Skip pre-execution hooks")
134193
viper.BindPFlag("dry-run", execCmd.Flags().Lookup("dry-run"))
194+
viper.BindPFlag("skip-hooks", execCmd.Flags().Lookup("skip-hooks"))
135195
rootCmd.AddCommand(execCmd)
136196
}

0 commit comments

Comments
 (0)