diff --git a/README.md b/README.md index 3779389..9b4411e 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,26 @@ case $1 in esac ``` +### Pre-Run Hooks + +Execute custom scripts before your main scripts run. Perfect for: +- Environment validation and setup +- Dependency checking +- Authentication checks +- Audit logging + +Create a `.hooks.d/` directory in your scripts root and add numbered hooks: + +```bash +# Executable hook - runs as separate process +.hooks.d/00-check-deps + +# Sourced hook - runs in same shell, can modify environment +.hooks.d/05-set-env.source +``` + +See [docs/hooks.md](./docs/hooks.md) for complete guide with examples. + ### Flexible Root Detection 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 - ✅ Environment variable injection - ✅ Structured logging with levels - ✅ Generated documentation +- ✅ Pre-run hooks (.hooks.d folder execution) ### Planned - ⏳ ActiveHelp integration for contextual assistance -- ⏳ Pre/post hooks (hooks.d folder execution) - ⏳ Enhanced directory help (show all subcommands in tree) - ⏳ Improved completion output filtering @@ -318,6 +338,7 @@ Make sure: - [Your First Script](#your-first-script) - Create your first script - [Writing Scripts Guide](./docs/writing-scripts.md) - Comprehensive guide to writing scripts - [Completion Guide](./docs/completion-guide.md) - Implement custom tab completions +- [Pre-Run Hooks Guide](./docs/hooks.md) - Add validation and setup hooks - [Migration Guide](./docs/migration.md) - Migrate from original tome/sub ### Core Documentation diff --git a/cmd/exec.go b/cmd/exec.go index 91f8c6e..bbbda89 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -6,6 +6,7 @@ package cmd import ( "fmt" "os" + "os/exec" "path" "path/filepath" "strings" @@ -80,11 +81,67 @@ func ExecRunE(cmd *cobra.Command, args []string) error { envs = append(envs, fmt.Sprintf("%s_ROOT=%s", executableAsEnvPrefix, absRootDir)) envs = append(envs, fmt.Sprintf("%s_EXECUTABLE=%s", executableAsEnvPrefix, config.ExecutableName())) - args = append([]string{maybeFile}, maybeArgs...) - execOrLog(maybeFile, args, envs) + // Check for hooks and generate wrapper if needed + var execTarget string + var execArgs []string + + if !skipHooks { + hookRunner := NewHookRunner(config) + hooks, err := hookRunner.DiscoverHooks() + if err != nil { + fmt.Printf("Error discovering hooks: %v\n", err) + os.Exit(1) + } + + if len(hooks) > 0 { + // Generate wrapper script content + wrapperContent, err := hookRunner.GenerateWrapperScriptContent(hooks, executable, maybeArgs) + if err != nil { + fmt.Printf("Error generating wrapper script: %v\n", err) + os.Exit(1) + } + + // Execute shell with inline script instead of script directly + shellPath, err := findShell() + if err != nil { + fmt.Printf("Error finding shell: %v\n", err) + os.Exit(1) + } + execTarget = shellPath + // Use basename of shell path for argv[0] + shellName := filepath.Base(shellPath) + execArgs = []string{shellName, "-c", wrapperContent} + } else { + // No hooks, execute script directly + execTarget = executable + execArgs = append([]string{executable}, maybeArgs...) + } + } else { + // Skip hooks, execute script directly + execTarget = executable + execArgs = append([]string{executable}, maybeArgs...) + } + + execOrLog(execTarget, execArgs, envs) return nil } +// findShell locates a POSIX shell, preferring bash but falling back to sh if unavailable +func findShell() (string, error) { + // Try bash first + if bashPath, err := exec.LookPath("bash"); err == nil { + return bashPath, nil + } + + // Fall back to sh (POSIX standard) + if shPath, err := exec.LookPath("sh"); err == nil { + log.Debugw("bash not found, using sh as fallback", "path", shPath) + return shPath, nil + } + + return "", fmt.Errorf("neither bash nor sh found") +} + func execOrLog(arv0 string, argv []string, env []string) { if dryRun { 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{ } var dryRun bool +var skipHooks bool func init() { execCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Dry run the exec command") + execCmd.Flags().BoolVar(&skipHooks, "skip-hooks", false, "Skip pre-execution hooks") viper.BindPFlag("dry-run", execCmd.Flags().Lookup("dry-run")) + viper.BindPFlag("skip-hooks", execCmd.Flags().Lookup("skip-hooks")) rootCmd.AddCommand(execCmd) } diff --git a/cmd/exec_hooks_integration_test.go b/cmd/exec_hooks_integration_test.go new file mode 100644 index 0000000..9a8772e --- /dev/null +++ b/cmd/exec_hooks_integration_test.go @@ -0,0 +1,308 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestExecWithHooks tests the full integration of hooks with the exec command +func TestExecWithHooks(t *testing.T) { + t.Run("exec runs hooks before script", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a hook that writes to a file + outputFile := filepath.Join(tmpDir, "execution-order.txt") + hookContent := `#!/bin/bash +echo "hook executed" >> ` + outputFile + ` +exit 0 +` + hookPath := filepath.Join(hooksDir, "00-test-hook") + if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil { + t.Fatal(err) + } + + // Create target script that also writes to the file + scriptContent := `#!/bin/bash +echo "script executed" >> ` + outputFile + ` +exit 0 +` + scriptPath := filepath.Join(tmpDir, "test-script") + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { + t.Fatal(err) + } + + // Setup config + _ = setupTestConfig(t, tmpDir, "tome-cli") + + // Simulate exec command + skipHooks = false + err := ExecRunE(nil, []string{"test-script"}) + if err != nil { + t.Fatalf("ExecRunE failed: %v", err) + } + + // Note: ExecRunE uses syscall.Exec which replaces the process + // So we can't verify the output file in this test + // The integration tests in hooks_integration_test.go cover actual execution + }) + + t.Run("exec with sourced hook modifies environment", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create sourced hook + hookContent := `export TEST_VAR="from_hook" +` + hookPath := filepath.Join(hooksDir, "05-env.source") + if err := os.WriteFile(hookPath, []byte(hookContent), 0644); err != nil { + t.Fatal(err) + } + + // Create script that uses the variable + outputFile := filepath.Join(tmpDir, "script-output.txt") + scriptContent := `#!/bin/bash +echo "TEST_VAR=$TEST_VAR" > ` + outputFile + ` +exit 0 +` + scriptPath := filepath.Join(tmpDir, "test-script") + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { + t.Fatal(err) + } + + // Setup config + config := setupTestConfig(t, tmpDir, "tome-cli") + + // Test hook discovery and wrapper generation + skipHooks = false + hookRunner := NewHookRunner(config) + hooks, err := hookRunner.DiscoverHooks() + if err != nil { + t.Fatal(err) + } + + if len(hooks) != 1 { + t.Fatalf("Expected 1 hook, got %d", len(hooks)) + } + + if !hooks[0].Sourced { + t.Error("Hook should be marked as sourced") + } + + wrapperContent, err := hookRunner.GenerateWrapperScriptContent(hooks, scriptPath, []string{}) + if err != nil { + t.Fatal(err) + } + // Verify wrapper contains source command + if !strings.Contains(wrapperContent, "source "+hookPath) { + t.Error("Wrapper should contain source command for .source hook") + } + }) + + t.Run("exec with --skip-hooks flag skips hooks", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a hook + hookPath := filepath.Join(hooksDir, "00-test-hook") + if err := os.WriteFile(hookPath, []byte("#!/bin/bash\nexit 1\n"), 0755); err != nil { + t.Fatal(err) + } + + // Create target script + scriptPath := filepath.Join(tmpDir, "test-script") + if err := os.WriteFile(scriptPath, []byte("#!/bin/bash\nexit 0\n"), 0755); err != nil { + t.Fatal(err) + } + + // Setup config + config := setupTestConfig(t, tmpDir, "tome-cli") + + // Set skipHooks flag + skipHooks = true + + // Test that hooks are discovered but not used + hookRunner := NewHookRunner(config) + hooks, err := hookRunner.DiscoverHooks() + if err != nil { + t.Fatal(err) + } + + if len(hooks) != 1 { + t.Fatalf("Expected 1 hook to be discovered, got %d", len(hooks)) + } + + // In the actual exec, hooks would be skipped due to skipHooks flag + // We can't test the full exec here due to syscall.Exec, but we verify + // the flag is honored in the logic + }) + + t.Run("exec discovers hooks from .hooks.d", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create multiple hooks + hooks := []struct { + name string + content string + mode os.FileMode + }{ + {"00-first", "#!/bin/bash\necho first\n", 0755}, + {"05-env.source", "export VAR=value\n", 0644}, + {"10-second", "#!/bin/bash\necho second\n", 0755}, + } + + for _, hook := range hooks { + hookPath := filepath.Join(hooksDir, hook.name) + if err := os.WriteFile(hookPath, []byte(hook.content), hook.mode); err != nil { + t.Fatal(err) + } + } + + // Create target script + scriptPath := filepath.Join(tmpDir, "test-script") + if err := os.WriteFile(scriptPath, []byte("#!/bin/bash\nexit 0\n"), 0755); err != nil { + t.Fatal(err) + } + + // Setup config and test discovery + config := setupTestConfig(t, tmpDir, "tome-cli") + hookRunner := NewHookRunner(config) + discoveredHooks, err := hookRunner.DiscoverHooks() + if err != nil { + t.Fatal(err) + } + + if len(discoveredHooks) != 3 { + t.Fatalf("Expected 3 hooks, got %d", len(discoveredHooks)) + } + + // Verify order + expectedOrder := []string{"00-first", "05-env.source", "10-second"} + for i, hook := range discoveredHooks { + if hook.Name != expectedOrder[i] { + t.Errorf("Hook %d: expected %s, got %s", i, expectedOrder[i], hook.Name) + } + } + + // Verify sourced flag + if discoveredHooks[0].Sourced { + t.Error("00-first should not be sourced") + } + if !discoveredHooks[1].Sourced { + t.Error("05-env.source should be sourced") + } + if discoveredHooks[2].Sourced { + t.Error("10-second should not be sourced") + } + }) + + t.Run("exec handles missing .hooks.d gracefully", func(t *testing.T) { + tmpDir := t.TempDir() + // No .hooks.d directory created + + // Create target script + scriptPath := filepath.Join(tmpDir, "test-script") + if err := os.WriteFile(scriptPath, []byte("#!/bin/bash\nexit 0\n"), 0755); err != nil { + t.Fatal(err) + } + + // Setup config + config := setupTestConfig(t, tmpDir, "tome-cli") + + // Should not error when .hooks.d doesn't exist + skipHooks = false + hookRunner := NewHookRunner(config) + hooks, err := hookRunner.DiscoverHooks() + if err != nil { + t.Fatalf("DiscoverHooks should not error on missing .hooks.d: %v", err) + } + + if len(hooks) != 0 { + t.Errorf("Expected 0 hooks when .hooks.d missing, got %d", len(hooks)) + } + }) + + t.Run("exec passes script args to wrapper", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a hook + hookPath := filepath.Join(hooksDir, "00-hook") + if err := os.WriteFile(hookPath, []byte("#!/bin/bash\nexit 0\n"), 0755); err != nil { + t.Fatal(err) + } + + // Create target script + scriptPath := filepath.Join(tmpDir, "test-script") + if err := os.WriteFile(scriptPath, []byte("#!/bin/bash\nexit 0\n"), 0755); err != nil { + t.Fatal(err) + } + + // Setup config + config := setupTestConfig(t, tmpDir, "tome-cli") + hookRunner := NewHookRunner(config) + hooks, err := hookRunner.DiscoverHooks() + if err != nil { + t.Fatal(err) + } + + // Generate wrapper with args + args := []string{"arg1", "arg2", "arg3"} + wrapperContent, err := hookRunner.GenerateWrapperScriptContent(hooks, scriptPath, args) + if err != nil { + t.Fatal(err) + } + // Verify wrapper contains args + if !strings.Contains(wrapperContent,"exec "+scriptPath+" arg1 arg2 arg3") { + t.Error("Wrapper should contain script path and arguments") + } + + // Verify environment variable is set + if !strings.Contains(wrapperContent,`TOME_SCRIPT_ARGS="arg1 arg2 arg3"`) { + t.Error("Wrapper should set TOME_SCRIPT_ARGS with all arguments") + } + }) +} + +// TestExecFlags tests exec command flags +func TestExecFlags(t *testing.T) { + t.Run("skip-hooks flag exists", func(t *testing.T) { + flag := execCmd.Flags().Lookup("skip-hooks") + if flag == nil { + t.Fatal("--skip-hooks flag not found") + } + + if flag.DefValue != "false" { + t.Errorf("Expected default value 'false', got '%s'", flag.DefValue) + } + + if flag.Usage != "Skip pre-execution hooks" { + t.Errorf("Unexpected usage text: %s", flag.Usage) + } + }) + + t.Run("dry-run flag exists", func(t *testing.T) { + flag := execCmd.Flags().Lookup("dry-run") + if flag == nil { + t.Fatal("--dry-run flag not found") + } + }) +} diff --git a/cmd/hooks.go b/cmd/hooks.go new file mode 100644 index 0000000..429c801 --- /dev/null +++ b/cmd/hooks.go @@ -0,0 +1,185 @@ +package cmd + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "text/template" + + shellescape "al.essio.dev/pkg/shellescape" + "github.com/gobeam/stringy" +) + +type Hook struct { + Path string + Name string + Sourced bool // true if filename ends with .source +} + +type HookRunner struct { + rootDir string + config *Config +} + +func NewHookRunner(config *Config) *HookRunner { + return &HookRunner{ + rootDir: config.RootDir(), + config: config, + } +} + +// DiscoverHooks finds all hooks in .hooks.d/ +func (hr *HookRunner) DiscoverHooks() ([]Hook, error) { + hooksDir := filepath.Join(hr.rootDir, ".hooks.d") + + // Check if hooks directory exists + if _, err := os.Stat(hooksDir); os.IsNotExist(err) { + log.Debugw(".hooks.d directory not found", "path", hooksDir) + return []Hook{}, nil + } + + entries, err := os.ReadDir(hooksDir) + if err != nil { + return nil, fmt.Errorf("failed to read .hooks.d: %w", err) + } + + var hooks []Hook + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + fullPath := filepath.Join(hooksDir, name) + + // Check if this is a sourced hook by file extension + sourced := strings.HasSuffix(name, ".source") + + // If not a sourced hook, verify it's executable + if !sourced { + fileInfo, err := entry.Info() + if err != nil { + log.Warnw("failed to stat hook", "path", fullPath, "error", err) + continue + } + + if !isExecutableByOwner(fileInfo.Mode()) { + log.Warnw("skipping non-executable hook without .source suffix", "path", fullPath) + continue + } + } + + hook := Hook{ + Path: fullPath, + Name: name, + Sourced: sourced, + } + + hooks = append(hooks, hook) + log.Debugw("discovered hook", "path", fullPath, "sourced", hook.Sourced) + } + + // Sort by name lexicographically (00- comes before 10-, etc.) + sort.Slice(hooks, func(i, j int) bool { + return hooks[i].Name < hooks[j].Name + }) + + log.Debugw("discovered hooks", "count", len(hooks)) + return hooks, nil +} + +const wrapperScriptTemplate = `set -e +{{range .Env -}} +export {{.}} +{{end}} +{{range .Hooks -}} +# Hook: {{.Name}} +{{if .Sourced -}} +if ! source "{{.Path}}"; then + echo 'Error: pre-hook failed: {{.Name}} (sourcing failed)' >&2 + exit 1 +fi +{{else -}} +if ! "{{.Path}}"; then + echo 'Error: pre-hook failed: {{.Name}}' >&2 + exit 1 +fi +{{end}} +{{end -}} +# Execute target script +{{if .ScriptArgs -}} +exec "{{.ScriptPath}}" {{.ScriptArgs}} +{{else -}} +exec "{{.ScriptPath}}" +{{end -}} +` + +type wrapperScriptData struct { + Env []string + Hooks []Hook + ScriptPath string + ScriptArgs string // Will be properly quoted when built +} + +// GenerateWrapperScriptContent creates shell script content that sources/executes hooks and execs the target +func (hr *HookRunner) GenerateWrapperScriptContent(hooks []Hook, scriptPath string, scriptArgs []string) (string, error) { + if len(hooks) == 0 { + // No hooks, no wrapper needed + return "", nil + } + + // Prepare template data with properly quoted args using shellescape library + quotedArgs := "" + if len(scriptArgs) > 0 { + quoted := make([]string, len(scriptArgs)) + for i, arg := range scriptArgs { + quoted[i] = shellescape.Quote(arg) + } + quotedArgs = strings.Join(quoted, " ") + } + + data := wrapperScriptData{ + Env: hr.buildHookEnv("", scriptPath, scriptArgs), + Hooks: hooks, + ScriptPath: scriptPath, + ScriptArgs: quotedArgs, + } + + // Parse and execute template + tmpl, err := template.New("wrapper").Parse(wrapperScriptTemplate) + if err != nil { + return "", fmt.Errorf("failed to parse wrapper template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute wrapper template: %w", err) + } + + log.Debugw("generated wrapper script content") + return buf.String(), nil +} + +func (hr *HookRunner) buildHookEnv(hookPath, scriptPath string, scriptArgs []string) []string { + var env []string + + // Add tome-cli standard vars + absRootDir, _ := filepath.Abs(hr.config.RootDir()) + env = append(env, fmt.Sprintf("TOME_ROOT=%s", absRootDir)) + env = append(env, fmt.Sprintf("TOME_EXECUTABLE=%s", hr.config.ExecutableName())) + + // Add uppercase executable-specific vars + executableAsEnvPrefix := strings.ToUpper(stringy.New(hr.config.ExecutableName()).SnakeCase().Get()) + env = append(env, fmt.Sprintf("%s_ROOT=%s", executableAsEnvPrefix, absRootDir)) + env = append(env, fmt.Sprintf("%s_EXECUTABLE=%s", executableAsEnvPrefix, hr.config.ExecutableName())) + + // Add script-specific vars + env = append(env, fmt.Sprintf("TOME_SCRIPT_PATH=%s", scriptPath)) + env = append(env, fmt.Sprintf("TOME_SCRIPT_NAME=%s", filepath.Base(scriptPath))) + env = append(env, fmt.Sprintf(`TOME_SCRIPT_ARGS="%s"`, strings.Join(scriptArgs, " "))) + + return env +} diff --git a/cmd/hooks_integration_test.go b/cmd/hooks_integration_test.go new file mode 100644 index 0000000..80d9be1 --- /dev/null +++ b/cmd/hooks_integration_test.go @@ -0,0 +1,787 @@ +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// executeWrapperContent executes wrapper script content using bash -c +func executeWrapperContent(content string) ([]byte, error) { + cmd := exec.Command("bash", "-c", content) + return cmd.CombinedOutput() +} + +// executeWrapperContentWithSh executes wrapper script content using sh -c +func executeWrapperContentWithSh(content string) ([]byte, error) { + cmd := exec.Command("sh", "-c", content) + return cmd.CombinedOutput() +} + +// TestHookExecution tests end-to-end hook execution +func TestHookExecution(t *testing.T) { + t.Run("executable hook runs successfully", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a hook that writes to a file + outputFile := filepath.Join(tmpDir, "hook-output.txt") + hookContent := `#!/bin/bash +echo "hook executed" > ` + outputFile + ` +exit 0 +` + hookPath := filepath.Join(hooksDir, "00-test") + if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil { + t.Fatal(err) + } + + // Create target script + scriptContent := `#!/bin/bash +echo "script executed" +exit 0 +` + scriptPath := filepath.Join(tmpDir, "test-script") + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { + t.Fatal(err) + } + + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Fatal(err) + } + + wrapperContent, err := hr.GenerateWrapperScriptContent(hooks, scriptPath, []string{}) + if err != nil { + t.Fatal(err) + } + + // Execute wrapper + output, err := executeWrapperContent(wrapperContent) + if err != nil { + t.Fatalf("Wrapper execution failed: %v, output: %s", err, output) + } + + // Verify hook executed + if _, err := os.Stat(outputFile); os.IsNotExist(err) { + t.Error("Hook did not execute - output file not created") + } + + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(string(content), "hook executed") { + t.Errorf("Hook output incorrect: %s", content) + } + }) + + t.Run("sourced hook modifies environment", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create sourced hook that exports variable + hookContent := `export TEST_VAR="from_hook" +export TEST_VAR2="another_value" +` + hookPath := filepath.Join(hooksDir, "05-env.source") + if err := os.WriteFile(hookPath, []byte(hookContent), 0644); err != nil { + t.Fatal(err) + } + + // Create script that uses the variable + outputFile := filepath.Join(tmpDir, "script-output.txt") + scriptContent := `#!/bin/bash +echo "TEST_VAR=$TEST_VAR" > ` + outputFile + ` +echo "TEST_VAR2=$TEST_VAR2" >> ` + outputFile + ` +exit 0 +` + scriptPath := filepath.Join(tmpDir, "test-script") + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { + t.Fatal(err) + } + + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Fatal(err) + } + + wrapperContent, err := hr.GenerateWrapperScriptContent(hooks, scriptPath, []string{}) + if err != nil { + t.Fatal(err) + } + + // Execute wrapper + output, err := executeWrapperContent(wrapperContent) + if err != nil { + t.Fatalf("Wrapper execution failed: %v, output: %s", err, output) + } + + // Verify script received the environment variable + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatal(err) + } + + outputStr := string(content) + if !strings.Contains(outputStr, "TEST_VAR=from_hook") { + t.Errorf("Sourced hook did not set TEST_VAR correctly: %s", outputStr) + } + if !strings.Contains(outputStr, "TEST_VAR2=another_value") { + t.Errorf("Sourced hook did not set TEST_VAR2 correctly: %s", outputStr) + } + }) + + t.Run("hook failure aborts execution", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create failing hook + hookContent := `#!/bin/bash +echo "hook failed" >&2 +exit 1 +` + hookPath := filepath.Join(hooksDir, "00-fail") + if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil { + t.Fatal(err) + } + + // Create script that should not execute + outputFile := filepath.Join(tmpDir, "script-output.txt") + scriptContent := `#!/bin/bash +echo "script executed" > ` + outputFile + ` +exit 0 +` + scriptPath := filepath.Join(tmpDir, "test-script") + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { + t.Fatal(err) + } + + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Fatal(err) + } + + wrapperContent, err := hr.GenerateWrapperScriptContent(hooks, scriptPath, []string{}) + if err != nil { + t.Fatal(err) + } + + // Execute wrapper - should fail + output, err := executeWrapperContent(wrapperContent) + if err == nil { + t.Fatal("Expected wrapper to fail due to hook failure") + } + + // Verify error message mentions hook + if !strings.Contains(string(output), "pre-hook failed") { + t.Errorf("Error output should mention pre-hook failure: %s", output) + } + + // Verify script did not execute + if _, err := os.Stat(outputFile); err == nil { + t.Error("Script should not have executed after hook failure") + } + }) + + t.Run("multiple hooks execute in order", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + outputFile := filepath.Join(tmpDir, "execution-order.txt") + + // Create three hooks that append to a file + hooks := []struct { + name string + content string + }{ + {"00-first", `#!/bin/bash +echo "first" >> ` + outputFile + ` +`}, + {"10-second", `#!/bin/bash +echo "second" >> ` + outputFile + ` +`}, + {"20-third", `#!/bin/bash +echo "third" >> ` + outputFile + ` +`}, + } + + for _, hook := range hooks { + hookPath := filepath.Join(hooksDir, hook.name) + if err := os.WriteFile(hookPath, []byte(hook.content), 0755); err != nil { + t.Fatal(err) + } + } + + // Create target script + scriptContent := `#!/bin/bash +echo "script" >> ` + outputFile + ` +exit 0 +` + scriptPath := filepath.Join(tmpDir, "test-script") + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { + t.Fatal(err) + } + + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + discoveredHooks, err := hr.DiscoverHooks() + if err != nil { + t.Fatal(err) + } + + wrapperContent, err := hr.GenerateWrapperScriptContent(discoveredHooks, scriptPath, []string{}) + if err != nil { + t.Fatal(err) + } + // Execute wrapper + output, err := executeWrapperContent(wrapperContent) + if err != nil { + t.Fatalf("Wrapper execution failed: %v, output: %s", err, output) + } + + // Verify execution order + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatal(err) + } + + lines := strings.Split(strings.TrimSpace(string(content)), "\n") + expected := []string{"first", "second", "third", "script"} + + if len(lines) != len(expected) { + t.Fatalf("Expected %d lines, got %d: %v", len(expected), len(lines), lines) + } + + for i, line := range lines { + if line != expected[i] { + t.Errorf("Line %d: expected '%s', got '%s'", i, expected[i], line) + } + } + }) + + t.Run("mixed executable and sourced hooks", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + outputFile := filepath.Join(tmpDir, "mixed-output.txt") + + // Create executable hook + execHook := `#!/bin/bash +echo "exec hook" >> ` + outputFile + ` +` + execPath := filepath.Join(hooksDir, "00-exec") + if err := os.WriteFile(execPath, []byte(execHook), 0755); err != nil { + t.Fatal(err) + } + + // Create sourced hook + sourceHook := `export FROM_SOURCE="sourced_value" +echo "source hook" >> ` + outputFile + ` +` + sourcePath := filepath.Join(hooksDir, "05-source.source") + if err := os.WriteFile(sourcePath, []byte(sourceHook), 0644); err != nil { + t.Fatal(err) + } + + // Create script that uses sourced variable + scriptContent := `#!/bin/bash +echo "script: FROM_SOURCE=$FROM_SOURCE" >> ` + outputFile + ` +exit 0 +` + scriptPath := filepath.Join(tmpDir, "test-script") + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { + t.Fatal(err) + } + + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Fatal(err) + } + + wrapperContent, err := hr.GenerateWrapperScriptContent(hooks, scriptPath, []string{}) + if err != nil { + t.Fatal(err) + } + + // Execute wrapper + output, err := executeWrapperContent(wrapperContent) + if err != nil { + t.Fatalf("Wrapper execution failed: %v, output: %s", err, output) + } + + // Verify output + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatal(err) + } + + outputStr := string(content) + if !strings.Contains(outputStr, "exec hook") { + t.Error("Executable hook did not execute") + } + if !strings.Contains(outputStr, "source hook") { + t.Error("Sourced hook did not execute") + } + if !strings.Contains(outputStr, "FROM_SOURCE=sourced_value") { + t.Errorf("Script did not receive sourced variable: %s", outputStr) + } + }) + + t.Run("hook receives environment variables", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + outputFile := filepath.Join(tmpDir, "env-output.txt") + + // Create hook that writes env vars to file + hookContent := `#!/bin/bash +echo "TOME_ROOT=$TOME_ROOT" > ` + outputFile + ` +echo "TOME_EXECUTABLE=$TOME_EXECUTABLE" >> ` + outputFile + ` +echo "TOME_SCRIPT_PATH=$TOME_SCRIPT_PATH" >> ` + outputFile + ` +echo "TOME_SCRIPT_NAME=$TOME_SCRIPT_NAME" >> ` + outputFile + ` +echo "TOME_SCRIPT_ARGS=$TOME_SCRIPT_ARGS" >> ` + outputFile + ` +` + hookPath := filepath.Join(hooksDir, "00-env-check") + if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil { + t.Fatal(err) + } + + // Create target script + scriptPath := filepath.Join(tmpDir, "my-script") + scriptContent := `#!/bin/bash +exit 0 +` + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { + t.Fatal(err) + } + + config := setupTestConfig(t, tmpDir, "test-cli") + hr := NewHookRunner(config) + + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Fatal(err) + } + + wrapperContent, err := hr.GenerateWrapperScriptContent(hooks, scriptPath, []string{"arg1", "arg2"}) + if err != nil { + t.Fatal(err) + } + // Execute wrapper + output, err := executeWrapperContent(wrapperContent) + if err != nil { + t.Fatalf("Wrapper execution failed: %v, output: %s", err, output) + } + + // Verify environment variables + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatal(err) + } + + outputStr := string(content) + checks := map[string]string{ + "TOME_ROOT": tmpDir, + "TOME_EXECUTABLE": "test-cli", + "TOME_SCRIPT_PATH": scriptPath, + "TOME_SCRIPT_NAME": "my-script", + "TOME_SCRIPT_ARGS": "arg1 arg2", + } + + for key, expected := range checks { + expectedLine := key + "=" + expected + if !strings.Contains(outputStr, expectedLine) { + t.Errorf("Missing or incorrect %s: expected '%s' in:\n%s", key, expectedLine, outputStr) + } + } + }) + + t.Run("sourced hook failure aborts execution", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create sourced hook that fails + hookContent := `set -e +echo "This will fail" >&2 +false +` + hookPath := filepath.Join(hooksDir, "05-fail.source") + if err := os.WriteFile(hookPath, []byte(hookContent), 0644); err != nil { + t.Fatal(err) + } + + // Create script that should not execute + outputFile := filepath.Join(tmpDir, "script-output.txt") + scriptContent := `#!/bin/bash +echo "script executed" > ` + outputFile + ` +exit 0 +` + scriptPath := filepath.Join(tmpDir, "test-script") + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { + t.Fatal(err) + } + + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Fatal(err) + } + + wrapperContent, err := hr.GenerateWrapperScriptContent(hooks, scriptPath, []string{}) + if err != nil { + t.Fatal(err) + } + + // Execute wrapper - should fail + _, err = executeWrapperContent(wrapperContent) + if err == nil { + t.Fatal("Expected wrapper to fail due to sourced hook failure") + } + + // Verify script did not execute + if _, err := os.Stat(outputFile); err == nil { + t.Error("Script should not have executed after sourced hook failure") + } + }) +} + +// TestShellCompatibility tests that wrapper scripts work with sh, not just bash +func TestShellCompatibility(t *testing.T) { + t.Run("wrapper script executes with sh", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a simple POSIX-compliant hook + outputFile := filepath.Join(tmpDir, "hook-output.txt") + hookContent := `#!/bin/sh +echo "hook executed" > ` + outputFile + ` +exit 0 +` + hookPath := filepath.Join(hooksDir, "00-test-hook") + if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil { + t.Fatal(err) + } + + // Create target script + scriptOutput := filepath.Join(tmpDir, "script-output.txt") + scriptContent := `#!/bin/sh +echo "script executed" > ` + scriptOutput + ` +exit 0 +` + scriptPath := filepath.Join(tmpDir, "test-script") + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { + t.Fatal(err) + } + + // Generate wrapper and execute with sh + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Fatal(err) + } + + wrapperContent, err := hr.GenerateWrapperScriptContent(hooks, scriptPath, []string{}) + if err != nil { + t.Fatal(err) + } + + // Execute with sh explicitly + output, err := executeWrapperContentWithSh(wrapperContent) + if err != nil { + t.Fatalf("Wrapper execution with sh failed: %v, output: %s", err, output) + } + + // Verify hook executed + if _, err := os.Stat(outputFile); os.IsNotExist(err) { + t.Error("Hook did not execute") + } + + // Verify script executed + if _, err := os.Stat(scriptOutput); os.IsNotExist(err) { + t.Error("Script did not execute") + } + }) + + t.Run("sourced hook works with sh", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create sourced hook with POSIX syntax + hookContent := `# POSIX-compliant environment setup +TEST_VAR="from_hook" +export TEST_VAR +` + hookPath := filepath.Join(hooksDir, "05-env.source") + if err := os.WriteFile(hookPath, []byte(hookContent), 0644); err != nil { + t.Fatal(err) + } + + // Create script that checks the variable + outputFile := filepath.Join(tmpDir, "output.txt") + scriptContent := `#!/bin/sh +echo "TEST_VAR=$TEST_VAR" > ` + outputFile + ` +exit 0 +` + scriptPath := filepath.Join(tmpDir, "test-script") + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { + t.Fatal(err) + } + + // Generate and execute with sh + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Fatal(err) + } + + wrapperContent, err := hr.GenerateWrapperScriptContent(hooks, scriptPath, []string{}) + if err != nil { + t.Fatal(err) + } + + output, err := executeWrapperContentWithSh(wrapperContent) + if err != nil { + t.Fatalf("Wrapper execution with sh failed: %v, output: %s", err, output) + } + + // Verify variable was set + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(string(content), "TEST_VAR=from_hook") { + t.Errorf("Variable not set correctly. Got: %s", content) + } + }) + + t.Run("wrapper uses POSIX-compliant syntax", func(t *testing.T) { + tmpDir := t.TempDir() + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + // Create minimal hooks with tmpDir-based path + hookPath := filepath.Join(tmpDir, "hook1") + hooks := []Hook{ + { + Path: hookPath, + Name: "00-hook1", + Sourced: false, + }, + } + + wrapperContent, err := hr.GenerateWrapperScriptContent(hooks, "/fake/script", []string{"arg1", "arg2"}) + if err != nil { + t.Fatal(err) + } + + // Verify wrapper doesn't use bash-specific features + // Check it uses 'set -e' which is POSIX + if !strings.Contains(wrapperContent, "set -e") { + t.Error("Wrapper should use POSIX 'set -e'") + } + + // Verify it doesn't use bash-specific features like [[ + if strings.Contains(wrapperContent, "[[") { + t.Error("Wrapper should not use bash-specific [[ syntax") + } + + // Verify exec command is POSIX + if !strings.Contains(wrapperContent, "exec /fake/script") { + t.Error("Wrapper should use POSIX exec") + } + }) +} + +// TestHookScenarios tests realistic hook scenarios +func TestHookScenarios(t *testing.T) { + t.Run("scenario: environment validation and setup", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Hook 1: Validate required variable exists + validateHook := `#!/bin/bash +if [ -z "$REQUIRED_VAR" ]; then + echo "Error: REQUIRED_VAR not set" >&2 + exit 1 +fi +echo "Validation passed" +` + validatePath := filepath.Join(hooksDir, "00-validate") + if err := os.WriteFile(validatePath, []byte(validateHook), 0755); err != nil { + t.Fatal(err) + } + + // Hook 2: Set additional environment variables + setupHook := `export AWS_REGION="us-east-1" +export LOG_LEVEL="info" +` + setupPath := filepath.Join(hooksDir, "05-setup.source") + if err := os.WriteFile(setupPath, []byte(setupHook), 0644); err != nil { + t.Fatal(err) + } + + // Script that uses the environment + outputFile := filepath.Join(tmpDir, "script-env.txt") + scriptContent := `#!/bin/bash +echo "AWS_REGION=$AWS_REGION" > ` + outputFile + ` +echo "LOG_LEVEL=$LOG_LEVEL" >> ` + outputFile + ` +echo "REQUIRED_VAR=$REQUIRED_VAR" >> ` + outputFile + ` +exit 0 +` + scriptPath := filepath.Join(tmpDir, "deploy-script") + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { + t.Fatal(err) + } + + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Fatal(err) + } + + wrapperContent, err := hr.GenerateWrapperScriptContent(hooks, scriptPath, []string{}) + if err != nil { + t.Fatal(err) + } + + // Execute with REQUIRED_VAR set + cmd := exec.Command("bash", "-c", wrapperContent) + cmd.Env = append(os.Environ(), "REQUIRED_VAR=test_value") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Wrapper execution failed: %v, output: %s", err, output) + } + + // Verify script received all environment variables + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatal(err) + } + + outputStr := string(content) + if !strings.Contains(outputStr, "AWS_REGION=us-east-1") { + t.Error("AWS_REGION not set correctly") + } + if !strings.Contains(outputStr, "LOG_LEVEL=info") { + t.Error("LOG_LEVEL not set correctly") + } + if !strings.Contains(outputStr, "REQUIRED_VAR=test_value") { + t.Error("REQUIRED_VAR not passed through") + } + }) + + t.Run("scenario: audit logging", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + auditLog := filepath.Join(tmpDir, "audit.log") + + // Create audit logging hook + auditHook := `#!/bin/bash +timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +echo "$timestamp | $TOME_SCRIPT_NAME | $TOME_SCRIPT_ARGS" >> ` + auditLog + ` +` + auditPath := filepath.Join(hooksDir, "20-audit") + if err := os.WriteFile(auditPath, []byte(auditHook), 0755); err != nil { + t.Fatal(err) + } + + // Create target script + scriptContent := `#!/bin/bash +echo "Doing work..." +exit 0 +` + scriptPath := filepath.Join(tmpDir, "sensitive-operation") + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { + t.Fatal(err) + } + + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Fatal(err) + } + + wrapperContent, err := hr.GenerateWrapperScriptContent(hooks, scriptPath, []string{"--env", "production"}) + if err != nil { + t.Fatal(err) + } + // Execute wrapper + output, err := executeWrapperContent(wrapperContent) + if err != nil { + t.Fatalf("Wrapper execution failed: %v, output: %s", err, output) + } + + // Verify audit log was created + content, err := os.ReadFile(auditLog) + if err != nil { + t.Fatal(err) + } + + logStr := string(content) + if !strings.Contains(logStr, "sensitive-operation") { + t.Error("Audit log missing script name") + } + if !strings.Contains(logStr, "--env production") { + t.Error("Audit log missing script arguments") + } + }) +} diff --git a/cmd/hooks_test.go b/cmd/hooks_test.go new file mode 100644 index 0000000..0d4c577 --- /dev/null +++ b/cmd/hooks_test.go @@ -0,0 +1,619 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" +) + +// setupTestConfig sets up viper config for testing +func setupTestConfig(t *testing.T, rootDir, executableName string) *Config { + t.Helper() + + // Initialize logger if not already done + if log == nil { + log = createLogger("test", os.Stderr) + } + + // Save current values + oldRoot := viper.Get("root") + oldExec := viper.Get("executable") + + // Set test values + viper.Set("root", rootDir) + viper.Set("executable", executableName) + + // Restore on cleanup + t.Cleanup(func() { + if oldRoot != nil { + viper.Set("root", oldRoot) + } + if oldExec != nil { + viper.Set("executable", oldExec) + } + }) + + return NewConfig() +} + +// TestHookDiscovery tests the DiscoverHooks function +func TestHookDiscovery(t *testing.T) { + t.Run("missing hooks directory", func(t *testing.T) { + tmpDir := t.TempDir() + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Errorf("DiscoverHooks() returned error for missing directory: %v", err) + } + if len(hooks) != 0 { + t.Errorf("DiscoverHooks() returned hooks for missing directory, expected 0, got %d", len(hooks)) + } + }) + + t.Run("empty hooks directory", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Errorf("DiscoverHooks() returned error: %v", err) + } + if len(hooks) != 0 { + t.Errorf("DiscoverHooks() expected 0 hooks, got %d", len(hooks)) + } + }) + + t.Run("discovers executable hooks", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create executable hook + hookPath := filepath.Join(hooksDir, "00-test-hook") + if err := os.WriteFile(hookPath, []byte("#!/bin/bash\necho test"), 0755); err != nil { + t.Fatal(err) + } + + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Errorf("DiscoverHooks() returned error: %v", err) + } + if len(hooks) != 1 { + t.Fatalf("DiscoverHooks() expected 1 hook, got %d", len(hooks)) + } + if hooks[0].Name != "00-test-hook" { + t.Errorf("Hook name expected '00-test-hook', got '%s'", hooks[0].Name) + } + if hooks[0].Sourced { + t.Errorf("Hook should not be marked as sourced") + } + }) + + t.Run("discovers sourced hooks with .source suffix", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create sourced hook (no execute permission needed) + hookPath := filepath.Join(hooksDir, "05-env.source") + if err := os.WriteFile(hookPath, []byte("export FOO=bar"), 0644); err != nil { + t.Fatal(err) + } + + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Errorf("DiscoverHooks() returned error: %v", err) + } + if len(hooks) != 1 { + t.Fatalf("DiscoverHooks() expected 1 hook, got %d", len(hooks)) + } + if hooks[0].Name != "05-env.source" { + t.Errorf("Hook name expected '05-env.source', got '%s'", hooks[0].Name) + } + if !hooks[0].Sourced { + t.Errorf("Hook should be marked as sourced") + } + }) + + t.Run("skips non-executable files without .source suffix", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create non-executable hook without .source suffix + hookPath := filepath.Join(hooksDir, "10-no-exec") + if err := os.WriteFile(hookPath, []byte("#!/bin/bash\necho test"), 0644); err != nil { + t.Fatal(err) + } + + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Errorf("DiscoverHooks() returned error: %v", err) + } + if len(hooks) != 0 { + t.Errorf("DiscoverHooks() should skip non-executable hook, got %d hooks", len(hooks)) + } + }) + + t.Run("sorts hooks lexicographically", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create hooks in random order + hooks := []string{"20-third", "00-first", "10-second"} + for _, hook := range hooks { + hookPath := filepath.Join(hooksDir, hook) + if err := os.WriteFile(hookPath, []byte("#!/bin/bash\n"), 0755); err != nil { + t.Fatal(err) + } + } + + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + discoveredHooks, err := hr.DiscoverHooks() + if err != nil { + t.Errorf("DiscoverHooks() returned error: %v", err) + } + if len(discoveredHooks) != 3 { + t.Fatalf("DiscoverHooks() expected 3 hooks, got %d", len(discoveredHooks)) + } + + expected := []string{"00-first", "10-second", "20-third"} + for i, hook := range discoveredHooks { + if hook.Name != expected[i] { + t.Errorf("Hook at index %d expected '%s', got '%s'", i, expected[i], hook.Name) + } + } + }) + + t.Run("skips directories", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a subdirectory + subDir := filepath.Join(hooksDir, "subdir") + if err := os.Mkdir(subDir, 0755); err != nil { + t.Fatal(err) + } + + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Errorf("DiscoverHooks() returned error: %v", err) + } + if len(hooks) != 0 { + t.Errorf("DiscoverHooks() should skip directories, got %d hooks", len(hooks)) + } + }) + + t.Run("mixed executable and sourced hooks", func(t *testing.T) { + tmpDir := t.TempDir() + hooksDir := filepath.Join(tmpDir, ".hooks.d") + if err := os.Mkdir(hooksDir, 0755); err != nil { + t.Fatal(err) + } + + // Create executable hook + execHook := filepath.Join(hooksDir, "00-exec") + if err := os.WriteFile(execHook, []byte("#!/bin/bash\n"), 0755); err != nil { + t.Fatal(err) + } + + // Create sourced hook + sourceHook := filepath.Join(hooksDir, "05-env.source") + if err := os.WriteFile(sourceHook, []byte("export FOO=bar"), 0644); err != nil { + t.Fatal(err) + } + + // Create another executable hook + execHook2 := filepath.Join(hooksDir, "10-another") + if err := os.WriteFile(execHook2, []byte("#!/bin/bash\n"), 0755); err != nil { + t.Fatal(err) + } + + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hooks, err := hr.DiscoverHooks() + if err != nil { + t.Errorf("DiscoverHooks() returned error: %v", err) + } + if len(hooks) != 3 { + t.Fatalf("DiscoverHooks() expected 3 hooks, got %d", len(hooks)) + } + + // Verify order and types + if !hooks[0].Sourced && hooks[0].Name != "00-exec" { + t.Errorf("First hook should be executable '00-exec'") + } + if hooks[1].Sourced && hooks[1].Name != "05-env.source" { + t.Errorf("Second hook should be sourced '05-env.source'") + } + if !hooks[2].Sourced && hooks[2].Name != "10-another" { + t.Errorf("Third hook should be executable '10-another'") + } + }) +} + +// TestGenerateWrapperScriptContent tests the wrapper script generation +func TestGenerateWrapperScriptContent(t *testing.T) { + t.Run("no hooks returns empty content", func(t *testing.T) { + tmpDir := t.TempDir() + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + content, err := hr.GenerateWrapperScriptContent([]Hook{}, "/fake/script", []string{}) + if err != nil { + t.Errorf("GenerateWrapperScriptContent() returned error: %v", err) + } + if content != "" { + t.Errorf("GenerateWrapperScriptContent() expected empty content for no hooks, got '%s'", content) + } + }) + + t.Run("generates wrapper with executable hook", func(t *testing.T) { + tmpDir := t.TempDir() + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hookPath := filepath.Join(tmpDir, "00-test") + hooks := []Hook{ + { + Path: hookPath, + Name: "00-test", + Sourced: false, + }, + } + + content, err := hr.GenerateWrapperScriptContent(hooks, "/fake/script", []string{"arg1", "arg2"}) + if err != nil { + t.Errorf("GenerateWrapperScriptContent() returned error: %v", err) + } + if content == "" { + t.Fatal("GenerateWrapperScriptContent() returned empty content") + } + + // Check for hook execution (now quoted) + if !contains(content, `"`+hookPath+`"`) { + t.Error("Wrapper script missing hook execution") + } + // Check for target script exec (shellescape only quotes when necessary) + if !contains(content, `exec "/fake/script" arg1 arg2`) { + t.Error("Wrapper script missing target script exec") + } + }) + + t.Run("generates wrapper with sourced hook", func(t *testing.T) { + tmpDir := t.TempDir() + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hookPath := filepath.Join(tmpDir, "05-env.source") + hooks := []Hook{ + { + Path: hookPath, + Name: "05-env.source", + Sourced: true, + }, + } + + content, err := hr.GenerateWrapperScriptContent(hooks, "/fake/script", []string{}) + if err != nil { + t.Errorf("GenerateWrapperScriptContent() returned error: %v", err) + } + if content == "" { + t.Fatal("GenerateWrapperScriptContent() returned empty content") + } + + contentStr := content + // Check for source command (now quoted) + if !contains(contentStr, `source "`+hookPath+`"`) { + t.Error("Wrapper script missing source command for .source hook") + } + }) + + t.Run("wrapper includes environment variables", func(t *testing.T) { + tmpDir := t.TempDir() + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hookPath := filepath.Join(tmpDir, "00-test") + hooks := []Hook{ + { + Path: hookPath, + Name: "00-test", + Sourced: false, + }, + } + + content, err := hr.GenerateWrapperScriptContent(hooks, "/fake/script", []string{"arg1"}) + if err != nil { + t.Errorf("GenerateWrapperScriptContent() returned error: %v", err) + } + if content == "" { + t.Fatal("GenerateWrapperScriptContent() returned empty content") + } + + contentStr := content + // Check for environment variables + if !contains(contentStr, "TOME_ROOT=") { + t.Error("Wrapper script missing TOME_ROOT") + } + if !contains(contentStr, "TOME_EXECUTABLE=") { + t.Error("Wrapper script missing TOME_EXECUTABLE") + } + if !contains(contentStr, "TOME_SCRIPT_PATH=") { + t.Error("Wrapper script missing TOME_SCRIPT_PATH") + } + if !contains(contentStr, "TOME_SCRIPT_NAME=") { + t.Error("Wrapper script missing TOME_SCRIPT_NAME") + } + if !contains(contentStr, "TOME_SCRIPT_ARGS=") { + t.Error("Wrapper script missing TOME_SCRIPT_ARGS") + } + }) + + t.Run("wrapper is executable", func(t *testing.T) { + tmpDir := t.TempDir() + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hookPath := filepath.Join(tmpDir, "00-test") + hooks := []Hook{ + { + Path: hookPath, + Name: "00-test", + Sourced: false, + }, + } + + content, err := hr.GenerateWrapperScriptContent(hooks, "/fake/script", []string{}) + if err != nil { + t.Errorf("GenerateWrapperScriptContent() returned error: %v", err) + } + if content == "" { + t.Fatal("GenerateWrapperScriptContent() returned empty content") + } + + // Verify content is valid shell script with expected structure + if !contains(content, "set -e") { + t.Error("Wrapper script missing 'set -e' directive") + } + if !contains(content, `exec "/fake/script"`) { + t.Error("Wrapper script missing exec command for target script") + } + }) + + t.Run("wrapper handles multiple hooks in order", func(t *testing.T) { + tmpDir := t.TempDir() + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + hook1Path := filepath.Join(tmpDir, "00-first") + hook2Path := filepath.Join(tmpDir, "05-env.source") + hook3Path := filepath.Join(tmpDir, "10-second") + + hooks := []Hook{ + { + Path: hook1Path, + Name: "00-first", + Sourced: false, + }, + { + Path: hook2Path, + Name: "05-env.source", + Sourced: true, + }, + { + Path: hook3Path, + Name: "10-second", + Sourced: false, + }, + } + + content, err := hr.GenerateWrapperScriptContent(hooks, "/fake/script", []string{}) + if err != nil { + t.Errorf("GenerateWrapperScriptContent() returned error: %v", err) + } + if content == "" { + t.Fatal("GenerateWrapperScriptContent() returned empty content") + } + + contentStr := content + // Verify all hooks are present (now with quotes) + if !contains(contentStr, `"`+hook1Path+`"`) { + t.Error("Wrapper script missing first hook") + } + if !contains(contentStr, `source "`+hook2Path+`"`) { + t.Error("Wrapper script missing sourced hook") + } + if !contains(contentStr, `"`+hook3Path+`"`) { + t.Error("Wrapper script missing second hook") + } + + // Verify order (crude check - first hook should appear before second) + firstIdx := indexOf(contentStr, "00-first") + secondIdx := indexOf(contentStr, "05-env.source") + thirdIdx := indexOf(contentStr, "10-second") + + if firstIdx == -1 || secondIdx == -1 || thirdIdx == -1 { + t.Error("Not all hooks found in wrapper") + } + if firstIdx > secondIdx || secondIdx > thirdIdx { + t.Error("Hooks not in correct order") + } + }) + + t.Run("wrapper handles paths with spaces", func(t *testing.T) { + tmpDir := t.TempDir() + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + // Create hook and script paths with spaces + hookPath := filepath.Join(tmpDir, "hook with spaces") + scriptPath := filepath.Join(tmpDir, "script with spaces.sh") + + hooks := []Hook{ + { + Path: hookPath, + Name: "hook with spaces", + Sourced: false, + }, + } + + args := []string{"arg with spaces", "normal-arg", "another arg"} + content, err := hr.GenerateWrapperScriptContent(hooks, scriptPath, args) + if err != nil { + t.Errorf("GenerateWrapperScriptContent() returned error: %v", err) + } + if content == "" { + t.Fatal("GenerateWrapperScriptContent() returned empty content") + } + + // Verify hook path is quoted + if !contains(content, `"`+hookPath+`"`) { + t.Error("Wrapper script missing quoted hook path") + } + + // Verify script path is quoted + if !contains(content, `"`+scriptPath+`"`) { + t.Error("Wrapper script missing quoted script path") + } + + // Verify args with spaces are quoted but normal args are not + if !contains(content, `'arg with spaces'`) { + t.Error("Wrapper script missing quoted argument with spaces") + } + if !contains(content, `normal-arg`) { + t.Error("Wrapper script missing unquoted simple argument") + } + if !contains(content, `'another arg'`) { + t.Error("Wrapper script missing second quoted argument with spaces") + } + }) +} + +// TestBuildHookEnv tests environment variable building +func TestBuildHookEnv(t *testing.T) { + t.Run("builds standard environment variables", func(t *testing.T) { + tmpDir := t.TempDir() + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + env := hr.buildHookEnv("", "/path/to/script", []string{"arg1", "arg2"}) + + // Check for required variables + vars := map[string]bool{ + "TOME_ROOT=": false, + "TOME_EXECUTABLE=": false, + "TOME_SCRIPT_PATH=": false, + "TOME_SCRIPT_NAME=": false, + "TOME_SCRIPT_ARGS=": false, + "TOME_CLI_ROOT=": false, // uppercase snake case of executable + "TOME_CLI_EXECUTABLE=": false, + } + + for _, e := range env { + for key := range vars { + if contains(e, key) { + vars[key] = true + } + } + } + + for key, found := range vars { + if !found { + t.Errorf("Missing environment variable: %s", key) + } + } + }) + + t.Run("includes script args", func(t *testing.T) { + tmpDir := t.TempDir() + config := setupTestConfig(t, tmpDir, "tome-cli") + hr := NewHookRunner(config) + + env := hr.buildHookEnv("", "/path/to/script", []string{"arg1", "arg2", "arg3"}) + + found := false + for _, e := range env { + // Args are quoted to handle spaces properly in bash + if contains(e, `TOME_SCRIPT_ARGS="arg1 arg2 arg3"`) { + found = true + break + } + } + + if !found { + t.Errorf("TOME_SCRIPT_ARGS not set correctly. Got: %v", env) + } + }) + + t.Run("uses custom executable name", func(t *testing.T) { + tmpDir := t.TempDir() + config := setupTestConfig(t, tmpDir, "my-custom-cli") + hr := NewHookRunner(config) + + env := hr.buildHookEnv("", "/path/to/script", []string{}) + + found := false + for _, e := range env { + if contains(e, "MY_CUSTOM_CLI_ROOT=") { + found = true + break + } + } + + if !found { + t.Error("Custom executable environment variable not set") + } + }) +} + +// Helper functions +func contains(s, substr string) bool { + return len(s) > 0 && len(substr) > 0 && indexOf(s, substr) != -1 +} + +func indexOf(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/docs/hooks.md b/docs/hooks.md new file mode 100644 index 0000000..9c004f0 --- /dev/null +++ b/docs/hooks.md @@ -0,0 +1,441 @@ +# Pre-Run Hooks + +Pre-run hooks allow you to run scripts before your main scripts execute. They're perfect for environment validation, dependency checking, authentication, and setup tasks. + +## Quick Start + +Create a `.hooks.d` directory in your `TOME_ROOT`: + +```bash +mkdir -p $TOME_ROOT/.hooks.d +``` + +## Hook Types + +Hooks come in two flavors: + +### Executable Hooks (Separate Process) + +These hooks run as independent processes, perfect for validation and checks. + +**How to create:** + +1. Create a file in `.hooks.d/` **without** `.source` suffix +2. Add shebang: `#!/usr/bin/env bash` +3. Make it executable: `chmod +x .hooks.d/hook-name` +4. Use number prefixes to control order: `00-first`, `10-second`, `20-third` + +**Example:** `.hooks.d/00-check-deps`, `.hooks.d/10-validate` + +**Best for:** +- Validation checks +- Running external commands +- Dependency verification +- Authentication checks +- Audit logging + +### Sourced Hooks (Same Shell Context) + +These hooks run in the same shell as your target script, allowing them to modify the environment. + +**How to create:** + +1. Create a file in `.hooks.d/` **with** `.source` suffix +2. Add number prefix to control order: `05-env.source`, `15-setup.source` +3. No need to make executable (sourcing ignores execute permission) + +**Example:** `.hooks.d/05-set-env.source`, `.hooks.d/15-load-functions.source` + +**Best for:** +- Setting environment variables +- Defining shell functions +- Modifying PATH +- Loading secrets +- Conditional environment setup + +## Environment Variables + +All hooks receive these environment variables: + +| Variable | Description | Example | +|----------|-------------|---------| +| `TOME_ROOT` | Root directory containing scripts | `/home/user/scripts` | +| `TOME_EXECUTABLE` | Name of the CLI command | `tome-cli` or `kit` | +| `TOME_SCRIPT_PATH` | Full path to script about to run | `/home/user/scripts/deploy` | +| `TOME_SCRIPT_NAME` | Name of script about to run | `deploy` | +| `TOME_SCRIPT_ARGS` | Arguments passed to the script | `"production --force"` | + +**Note:** Variables exported by sourced hooks (`.source` suffix) are available to your main script. + +## Examples + +### Validate AWS Credentials (Executable) + +```bash +#!/usr/bin/env bash +# .hooks.d/00-check-aws-creds +# Make executable: chmod +x .hooks.d/00-check-aws-creds + +if ! aws sts get-caller-identity &>/dev/null; then + echo "Error: AWS credentials not configured" >&2 + exit 1 +fi + +echo "✓ AWS credentials valid" +``` + +### Set AWS Environment (Sourced) + +```bash +# .hooks.d/05-set-aws-env.source +# Note the .source suffix - this will be sourced + +# Set AWS environment based on script name +case "$TOME_SCRIPT_NAME" in + *-prod*) + export AWS_PROFILE="production" + export AWS_REGION="us-east-1" + ;; + *-staging*) + export AWS_PROFILE="staging" + export AWS_REGION="us-west-2" + ;; + *) + export AWS_PROFILE="development" + export AWS_REGION="us-west-1" + ;; +esac + +echo "✓ AWS environment: $AWS_PROFILE in $AWS_REGION" +``` + +### Check Dependencies (Executable) + +```bash +#!/usr/bin/env bash +# .hooks.d/10-check-deps +# Make executable: chmod +x .hooks.d/10-check-deps + +required_tools=("jq" "curl" "aws") + +for tool in "${required_tools[@]}"; do + if ! command -v "$tool" &>/dev/null; then + echo "Error: $tool not installed" >&2 + exit 1 + fi +done + +echo "✓ All dependencies available" +``` + +### Load Helper Functions (Sourced) + +```bash +# .hooks.d/15-functions.source +# Note the .source suffix + +# Log with timestamp +log() { + echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] $*" +} + +# Check if in production +is_production() { + [[ "$AWS_PROFILE" == "production" ]] +} + +# Export functions so scripts can use them +export -f log +export -f is_production + +echo "✓ Helper functions loaded" +``` + +### Audit Logging (Executable) + +```bash +#!/usr/bin/env bash +# .hooks.d/20-audit-log +# Make executable: chmod +x .hooks.d/20-audit-log + +log_file="$TOME_ROOT/.audit-log" +timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +echo "$timestamp | $USER | $TOME_SCRIPT_NAME $TOME_SCRIPT_ARGS" >> "$log_file" +echo "✓ Execution logged" +``` + +### Load Secrets (Sourced) + +```bash +# .hooks.d/25-load-secrets.source +# Note the .source suffix + +secrets_file="$TOME_ROOT/.secrets" + +if [ -f "$secrets_file" ]; then + source "$secrets_file" + echo "✓ Secrets loaded" +else + echo "Warning: $secrets_file not found" >&2 +fi +``` + +## Common Use Cases + +### Environment Validation + +Validate required environment variables before running scripts: + +```bash +#!/usr/bin/env bash +# .hooks.d/00-validate-env + +required_vars=("DATABASE_URL" "API_KEY") + +for var in "${required_vars[@]}"; do + if [ -z "${!var}" ]; then + echo "Error: $var not set" >&2 + exit 1 + fi +done + +echo "✓ Environment validated" +``` + +### Set Default Values + +Provide sensible defaults using sourced hooks: + +```bash +# .hooks.d/05-defaults.source + +export LOG_LEVEL="${LOG_LEVEL:-info}" +export TIMEOUT="${TIMEOUT:-30}" +export RETRY_COUNT="${RETRY_COUNT:-3}" + +echo "✓ Defaults set (LOG_LEVEL=$LOG_LEVEL)" +``` + +### Script-Specific Setup + +Use `TOME_SCRIPT_NAME` to customize behavior per script: + +```bash +# .hooks.d/10-script-setup.source + +# Enable debug mode for test scripts +if [[ "$TOME_SCRIPT_NAME" == test-* ]]; then + export DEBUG=1 + set -x +fi + +# Require confirmation for production scripts +if [[ "$TOME_SCRIPT_NAME" == *-prod ]]; then + read -p "Running production script. Continue? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted" >&2 + exit 1 + fi +fi +``` + +## Error Handling + +### Executable Hooks + +If an executable hook exits with non-zero status: +- ❌ Main script will NOT execute +- ❌ tome-cli exits with the hook's exit code +- 📋 Error message shows which hook failed + +**Example error:** +``` +Error: pre-hook failed: 10-check-deps +``` + +### Sourced Hooks + +If a sourced hook fails (bash errors, `set -e` triggered, etc.): +- ❌ Main script will NOT execute +- ❌ tome-cli exits with error +- 📋 Error message shows which hook failed + +**Example error:** +``` +Error: pre-hook failed: 05-env.source (sourcing failed) +``` + +## Skipping Hooks + +Skip all hooks for a single execution: + +```bash +tome-cli --skip-hooks exec my-script +``` + +This is useful for: +- Testing scripts without hooks +- Performance-critical operations +- Emergency maintenance + +## Debugging + +See detailed hook execution with the `--debug` flag: + +```bash +tome-cli --debug exec my-script +``` + +This shows: +- Which hooks were discovered +- Hook execution order +- Environment variables set +- Wrapper script path + +## Hook Ordering + +Hooks execute in **lexicographic order** by filename. Use number prefixes to control execution: + +``` +.hooks.d/ +├── 00-validate-env # Runs first +├── 05-set-env.source # Runs second +├── 10-check-deps # Runs third +├── 15-functions.source # Runs fourth +└── 20-audit-log # Runs last +``` + +**Recommended numbering:** +- `00-09`: Validation and checks +- `10-19`: Setup and configuration +- `20-29`: Logging and monitoring + +## Security Considerations + +⚠️ **Important Security Notes:** + +1. **Executable permissions**: Non-`.source` hooks must be executable to run +2. **Sourced hooks run in same context**: `.source` hooks can modify your shell environment - only use trusted code +3. **Explicit suffix**: The `.source` suffix makes it obvious which hooks modify environment +4. **Hooks run before every script**: Consider the security implications of automatic execution +5. **Hidden directory**: `.hooks.d` is hidden to prevent accidental modification + +**Best practices:** +- Review hooks regularly +- Use version control for `.hooks.d/` +- Document what each hook does +- Use `--debug` to verify hook behavior +- Test hooks in non-production first + +## Performance + +Hooks add minimal overhead: + +- ✅ Hook discovery: ~1ms (checks if `.hooks.d/` exists) +- ✅ Wrapper generation: ~1ms (simple string concatenation) +- ⏱️ Hook execution: Depends on your hooks +- 🗑️ Cleanup: Automatic (wrapper script removed after execution) + +**Tips for fast hooks:** +- Keep validation hooks simple +- Cache expensive checks when possible +- Use `--skip-hooks` for performance-critical operations +- Sourced hooks are slightly faster than executable hooks (no fork) + +## Why Only Pre-Run Hooks? + +tome-cli uses `syscall.Exec()` which replaces the process, making post-run hooks technically challenging. Pre-run hooks cover the most common use cases: + +✅ Environment validation +✅ Dependency checking +✅ Authentication +✅ Setup and configuration +✅ Audit logging (start) + +**Can't do with pre-hooks alone:** +❌ Cleanup after script execution +❌ Capture script exit codes +❌ Post-execution notifications + +If you need post-execution behavior, consider: +- Using trap in your scripts: `trap cleanup EXIT` +- Wrapper scripts that call tome-cli then do cleanup +- Process monitoring tools + +## Troubleshooting + +### Hook not running + +**Check:** +1. Is the hook in `.hooks.d/` directory? +2. Is it executable (if not `.source` suffix)? `chmod +x .hooks.d/hook-name` +3. Is the filename correct? (no spaces, proper prefix) +4. Run with `--debug` to see what hooks are discovered + +### Sourced hook not modifying environment + +**Remember:** +1. File must end with `.source` suffix +2. Use `export` for variables: `export VAR=value` +3. Functions need `export -f`: `export -f function_name` + +### Hook fails but script still runs + +**This shouldn't happen.** If a hook fails: +- ❌ Script should NOT run +- Check if you're using `--skip-hooks` +- Verify hook exit codes (`exit 1` for errors) + +### Want to skip specific hooks + +**Options:** +1. Rename hook to something that doesn't match pattern (add `.disabled`) +2. Remove execute permission for executable hooks +3. Move hook out of `.hooks.d/` temporarily +4. Use `--skip-hooks` to skip all hooks + +## Migration from Other Systems + +If you're coming from: + +### Git hooks +- tome hooks are pre-execution, not commit-based +- Use `.hooks.d/` not `.git/hooks/` +- No need for template installation + +### Make targets +- Replace prerequisite targets with pre-hooks +- Hooks run automatically, no manual dependencies + +### Shell aliases with checks +- Move validation logic into hooks +- Cleaner separation of concerns +- Easier to share across team + +## FAQ + +**Q: Can I use hooks with aliases?** +A: Yes! Hooks work with both `tome-cli exec` and any aliases you create. + +**Q: Do hooks run for tab completion?** +A: No, hooks only run when executing scripts, not during completion. + +**Q: Can I have script-specific hooks?** +A: Not yet. Currently only global `.hooks.d/` is supported. Use `TOME_SCRIPT_NAME` to customize behavior per script. + +**Q: What shells are supported?** +A: Hooks use `#!/usr/bin/env bash` by default. You can use any interpreter with proper shebang. + +**Q: Can hooks modify the script being executed?** +A: No, hooks run before execution but cannot modify the script file itself. + +**Q: Are there hook templates?** +A: Not yet, but check the `examples/.hooks.d/` directory for starter templates. + +## See Also + +- [Writing Scripts](./writing-scripts.md) - Guide to creating tome scripts +- [Completion Guide](./completion-guide.md) - Adding tab completion to scripts +- [Examples](../examples/) - Sample scripts and hooks diff --git a/examples/.hooks.d/00-test-hook b/examples/.hooks.d/00-test-hook new file mode 100755 index 0000000..97a7531 --- /dev/null +++ b/examples/.hooks.d/00-test-hook @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Test hook that validates environment and writes to a file + +# Write execution marker +echo "hook executed" >> /tmp/tome-hook-test.txt + +# Validate tome environment variables are set +if [ -z "$TOME_ROOT" ]; then + echo "Error: TOME_ROOT not set" >&2 + exit 1 +fi + +if [ -z "$TOME_EXECUTABLE" ]; then + echo "Error: TOME_EXECUTABLE not set" >&2 + exit 1 +fi + +# Write env to file for test verification +echo "TOME_ROOT=$TOME_ROOT" >> /tmp/tome-hook-test.txt +echo "TOME_SCRIPT_NAME=$TOME_SCRIPT_NAME" >> /tmp/tome-hook-test.txt + +exit 0 diff --git a/examples/.hooks.d/05-set-env.source b/examples/.hooks.d/05-set-env.source new file mode 100644 index 0000000..936cdab --- /dev/null +++ b/examples/.hooks.d/05-set-env.source @@ -0,0 +1,8 @@ +# Sourced hook that sets environment variables +# This file is sourced, so no shebang or execute permission needed + +export HOOK_SET_VAR="value_from_hook" +export ANOTHER_VAR="another_value" + +# Write marker to verify sourcing worked +echo "sourced hook executed" >> /tmp/tome-hook-test.txt diff --git a/examples/test-hooks b/examples/test-hooks new file mode 100755 index 0000000..b16fd20 --- /dev/null +++ b/examples/test-hooks @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# USAGE: test-hooks +# Script to verify hooks executed correctly + +# Check if hook set the environment variable +if [ "$HOOK_SET_VAR" = "value_from_hook" ]; then + echo "SUCCESS: Sourced hook set environment variable" +else + echo "FAIL: HOOK_SET_VAR not set by sourced hook" + exit 1 +fi + +# Verify hook execution file exists +if [ -f /tmp/tome-hook-test.txt ]; then + echo "SUCCESS: Hooks executed and created marker file" + cat /tmp/tome-hook-test.txt +else + echo "FAIL: Hook marker file not found" + exit 1 +fi + +# Clean up +rm -f /tmp/tome-hook-test.txt + +echo "All hooks tests passed!" +exit 0 diff --git a/go.mod b/go.mod index 3d17c33..a375b01 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/zph/tome-cli go 1.22.2 require ( + al.essio.dev/pkg/shellescape v1.6.0 github.com/gobeam/stringy v0.0.7 github.com/lithammer/dedent v1.1.0 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 diff --git a/go.sum b/go.sum index 4784873..cb5f5c4 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= +al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= @@ -14,6 +16,8 @@ github.com/gobeam/stringy v0.0.7 h1:TD8SfhedUoiANhW88JlJqfrMsihskIRpU/VTsHGnAps= github.com/gobeam/stringy v0.0.7/go.mod h1:W3620X9dJHf2FSZF5fRnWekHcHQjwmCz8ZQ2d1qloqE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= diff --git a/test/tome-cli.test.ts b/test/tome-cli.test.ts index 28b37c2..dde7b54 100644 --- a/test/tome-cli.test.ts +++ b/test/tome-cli.test.ts @@ -44,6 +44,7 @@ for await (let [executable, fn] of [["tome-cli", tome], ["wrapper.sh", wrapper]] "folder bar: ", "folder test-env-injection: ", "foo: ", + "test-hooks:", ]); }); @@ -114,3 +115,51 @@ Deno.test(`tome-cli: TOME_COMPLETION passed through as env`, async function (t): 'TOME_COMPLETION={"args":["folder","test-env-injection"],"last_arg":"test-env-injection","current_word":"a"}', ].sort()); }) + +// Hooks tests +for await (let [executable, fn] of [["tome-cli", tome], ["wrapper.sh", wrapper]]) { + executable = executable as string + fn = fn as () => any + + Deno.test(`${executable}: hooks execute before script`, async function (t): Promise { + // Clean up any previous test runs + try { + await Deno.remove("/tmp/tome-hook-test.txt"); + } catch { + // Ignore if file doesn't exist + } + + const { code, lines } = await fn(`exec test-hooks`); + assertEquals(code, 0); + + // Verify the test script passed (which means hooks ran successfully) + assertStringIncludes(lines.join("\n"), "SUCCESS: Sourced hook set environment variable"); + assertStringIncludes(lines.join("\n"), "SUCCESS: Hooks executed and created marker file"); + assertStringIncludes(lines.join("\n"), "All hooks tests passed!"); + }); + + Deno.test(`${executable}: --skip-hooks flag skips hooks`, async function (t): Promise { + // Clean up any previous test runs + try { + await Deno.remove("/tmp/tome-hook-test.txt"); + } catch { + // Ignore if file doesn't exist + } + + // Run with --skip-hooks - the test-hooks script should fail because + // HOOK_SET_VAR won't be set (sourced hook didn't run) + const { code } = await fn(`exec --skip-hooks test-hooks`); + + // Script should exit with non-zero because hook-set variable is missing + assertEquals(code, 1); + + // Verify marker file wasn't created (hooks didn't run) + let fileExists = true; + try { + await Deno.stat("/tmp/tome-hook-test.txt"); + } catch { + fileExists = false; + } + assertEquals(fileExists, false, "Hook marker file should not exist when hooks are skipped"); + }); +}