From 7dea9359f86eea6dd4e20497d4c0d932ed13acf7 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:06:47 -0700 Subject: [PATCH] feat(hooks): add hooks command to list configured lifecycle hooks Add `azd app hooks` to list the project-level lifecycle hooks configured in azure.yaml. For each hook (prerun, postrun, prestop, poststop) it shows the command, shell, continueOnError and interactive flags, and any Windows or POSIX override, in lifecycle order. Reuses the nil-safe Hooks accessors so an absent hooks block needs no special handling. Supports text and JSON output and prints a short message when nothing is configured. Adds table-driven unit tests and docs. Closes #510 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cli/docs/cli-reference.md | 44 ++++++ cli/docs/commands/hooks.md | 79 +++++++++++ cli/src/cmd/app/commands/hooks.go | 182 +++++++++++++++++++++++++ cli/src/cmd/app/commands/hooks_test.go | 138 +++++++++++++++++++ cli/src/cmd/app/main.go | 1 + 5 files changed, 444 insertions(+) create mode 100644 cli/docs/commands/hooks.md create mode 100644 cli/src/cmd/app/commands/hooks.go create mode 100644 cli/src/cmd/app/commands/hooks_test.go diff --git a/cli/docs/cli-reference.md b/cli/docs/cli-reference.md index 545ae68df..f4db44aca 100644 --- a/cli/docs/cli-reference.md +++ b/cli/docs/cli-reference.md @@ -75,6 +75,7 @@ azd app run --environment production | `health` | Monitor health status of services (static or streaming mode) | [→ Full Spec](commands/health.md) | | `logs` | View logs from running services | [→ Full Spec](commands/logs.md) | | `info` | Show information about running services | [→ Full Spec](commands/info.md) | +| `hooks` | List the lifecycle hooks configured in azure.yaml | [→ Full Spec](commands/hooks.md) | | `mcp` | Model Context Protocol server for AI assistant integration | [→ Full Spec](commands/mcp.md) | | `notifications` | Manage process notifications for service state changes | [→ Full Spec](commands/notifications.md) | | `version` | Show version information | [→ Full Spec](commands/version.md) | @@ -1052,6 +1053,49 @@ api --- +## `azd app hooks` + +Reads `azure.yaml` and lists the project-level lifecycle hooks. For each configured hook it shows the command it runs, the shell it uses, and any per-platform override for Windows or POSIX. Use it to confirm what will run around a `run` or `stop` without opening the file. + +### Usage + +```bash +azd app hooks [flags] +``` + +### Examples + +```bash +# List configured hooks +azd app hooks + +# JSON array of hooks +azd app hooks --output json +``` + +### Flags + +| Flag | Short | Type | Default | Description | +|------|-------|------|---------|-------------| +| `--output` | `-o` | string | `text` | Output format: `text` or `json` | + +### Lifecycle hooks + +| Hook | When it runs | +|------|--------------| +| `prerun` | before services start | +| `postrun` | after services stop following a run | +| `prestop` | before services are stopped | +| `poststop` | after services are stopped | + +### Notes + +- Hooks are listed in lifecycle order. Hooks that are not configured are omitted. +- A hook with a Windows or POSIX override shows the override command and shell on its own line. +- When no hooks are configured the command prints a short message and exits zero. + +--- + ## `azd app mcp` Model Context Protocol (MCP) server for AI assistant integration. Enables AI assistants like Claude Desktop and GitHub Copilot to interact with your azd app projects. diff --git a/cli/docs/commands/hooks.md b/cli/docs/commands/hooks.md new file mode 100644 index 000000000..8ab2730c4 --- /dev/null +++ b/cli/docs/commands/hooks.md @@ -0,0 +1,79 @@ +# azd app hooks + +List the project-level lifecycle hooks configured in azure.yaml. + +## Synopsis + +``` +azd app hooks [flags] +``` + +## Description + +The `hooks` command reads `azure.yaml` and lists the lifecycle hooks it defines. For each configured hook it shows the command it runs, the shell it uses, whether it continues on error or needs user interaction, and any per-platform override for Windows or POSIX. + +Hooks run around the development lifecycle: + +| Hook | When it runs | +|------|--------------| +| `prerun` | before services start | +| `postrun` | after services stop following a run | +| `prestop` | before services are stopped | +| `poststop` | after services are stopped | + +Use it to confirm what will run around `azd app run` and `azd app stop` without opening the file. When no hooks are configured the command prints a short message and exits zero. + +## Flags + +| Flag | Description | +|------|-------------| +| `-o, --output` | Output format (text, json) | + +## Examples + +### List configured hooks + +```bash +azd app hooks +``` + +``` +prerun + run: npm run setup + shell: bash +postrun + run: ./cleanup.sh + shell: (default) +``` + +### Hook with a platform override + +A hook that defines a Windows or POSIX override shows it on its own line: + +``` +prerun + run: ./setup.sh + shell: (default) + windows: setup.ps1 (shell: pwsh) +``` + +### Get JSON output + +```bash +azd app hooks --output json +``` + +```json +[ + { + "name": "prerun", + "run": "npm run setup", + "shell": "bash" + } +] +``` + +## See Also + +- [azd app run](run.md) - Runs prerun and postrun hooks around services +- [azd app stop](stop.md) - Runs prestop and poststop hooks around shutdown diff --git a/cli/src/cmd/app/commands/hooks.go b/cli/src/cmd/app/commands/hooks.go new file mode 100644 index 000000000..4793addde --- /dev/null +++ b/cli/src/cmd/app/commands/hooks.go @@ -0,0 +1,182 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/jongio/azd-app/cli/src/internal/service" + "github.com/jongio/azd-core/cliout" + + "github.com/spf13/cobra" +) + +// NewHooksCommand creates the hooks command. +func NewHooksCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "hooks", + Short: "List the lifecycle hooks configured in azure.yaml", + Long: `List the project-level lifecycle hooks configured in azure.yaml. + +Shows each configured hook (prerun, postrun, prestop, poststop) with the +command it runs, the shell it uses, and any per-platform override for Windows +or POSIX. Hooks run around the development lifecycle: + + prerun before services start + postrun after services stop following a run + prestop before services are stopped + poststop after services are stopped + +Examples: + # List configured hooks + azd app hooks + + # JSON object keyed by hook name + azd app hooks --output json`, + SilenceUsage: true, + Args: cobra.NoArgs, + RunE: runHooks, + } + + return cmd +} + +// hookOverride is a per-platform override of a hook. +type hookOverride struct { + Run string `json:"run,omitempty"` + Shell string `json:"shell,omitempty"` + ContinueOnError *bool `json:"continueOnError,omitempty"` + Interactive *bool `json:"interactive,omitempty"` +} + +// hookInfo is the resolved view of a single lifecycle hook. +type hookInfo struct { + Name string `json:"name"` + Run string `json:"run"` + Shell string `json:"shell,omitempty"` + ContinueOnError bool `json:"continueOnError,omitempty"` + Interactive bool `json:"interactive,omitempty"` + Windows *hookOverride `json:"windows,omitempty"` + Posix *hookOverride `json:"posix,omitempty"` +} + +// lifecycleHookOrder is the stable display order for the four lifecycle hooks. +var lifecycleHookOrder = []string{"prerun", "postrun", "prestop", "poststop"} + +func runHooks(_ *cobra.Command, _ []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + + azureYaml, err := service.ParseAzureYaml(cwd) + if err != nil { + return fmt.Errorf("failed to load azure.yaml: %w", err) + } + + hooks := collectHooks(azureYaml.Hooks) + + if cliout.IsJSON() { + return cliout.PrintJSON(hooks) + } + + printHooks(hooks) + return nil +} + +// collectHooks resolves the configured lifecycle hooks in display order. Hooks +// that are not configured are omitted. +func collectHooks(hooks *service.Hooks) []hookInfo { + sources := map[string]*service.Hook{ + "prerun": hooks.GetPrerun(), + "postrun": hooks.GetPostrun(), + "prestop": hooks.GetPrestop(), + "poststop": hooks.GetPoststop(), + } + + result := make([]hookInfo, 0, len(lifecycleHookOrder)) + for _, name := range lifecycleHookOrder { + h := sources[name] + if h == nil { + continue + } + result = append(result, hookInfo{ + Name: name, + Run: h.Run, + Shell: h.Shell, + ContinueOnError: h.ContinueOnError, + Interactive: h.Interactive, + Windows: toHookOverride(h.Windows), + Posix: toHookOverride(h.Posix), + }) + } + return result +} + +// toHookOverride converts a platform-specific hook to the reporting shape, +// returning nil when there is no override. +func toHookOverride(p *service.PlatformHook) *hookOverride { + if p == nil { + return nil + } + return &hookOverride{ + Run: p.Run, + Shell: p.Shell, + ContinueOnError: p.ContinueOnError, + Interactive: p.Interactive, + } +} + +// printHooks writes each configured hook and its details in text mode. +func printHooks(hooks []hookInfo) { + if len(hooks) == 0 { + cliout.Info("No lifecycle hooks are configured in azure.yaml") + return + } + + cliout.CommandHeader("hooks", "Configured lifecycle hooks") + for i, h := range hooks { + if i > 0 { + cliout.Newline() + } + cliout.Info("%s", h.Name) + cliout.Label("run", shellOrNote(h.Run)) + cliout.Label("shell", shellOrDefault(h.Shell)) + if h.ContinueOnError { + cliout.Label("continueOnError", "true") + } + if h.Interactive { + cliout.Label("interactive", "true") + } + if h.Windows != nil { + cliout.Label("windows", overrideSummary(h.Windows)) + } + if h.Posix != nil { + cliout.Label("posix", overrideSummary(h.Posix)) + } + } +} + +// shellOrDefault returns the shell name, or a note when the default shell is used. +func shellOrDefault(shell string) string { + if shell == "" { + return "(default)" + } + return shell +} + +// shellOrNote returns the command, or a note when no command is set. +func shellOrNote(run string) string { + if run == "" { + return "(none)" + } + return run +} + +// overrideSummary renders a per-platform override as a short one-line summary. +func overrideSummary(o *hookOverride) string { + summary := shellOrNote(o.Run) + if o.Shell != "" { + summary += fmt.Sprintf(" (shell: %s)", o.Shell) + } + return summary +} diff --git a/cli/src/cmd/app/commands/hooks_test.go b/cli/src/cmd/app/commands/hooks_test.go new file mode 100644 index 000000000..828359f64 --- /dev/null +++ b/cli/src/cmd/app/commands/hooks_test.go @@ -0,0 +1,138 @@ +package commands + +import ( + "encoding/json" + "testing" + + "github.com/jongio/azd-app/cli/src/internal/service" +) + +func boolPtr(b bool) *bool { return &b } + +func TestCollectHooksEmpty(t *testing.T) { + if got := collectHooks(nil); len(got) != 0 { + t.Errorf("expected no hooks for nil, got %+v", got) + } + if got := collectHooks(&service.Hooks{}); len(got) != 0 { + t.Errorf("expected no hooks for empty struct, got %+v", got) + } +} + +func TestCollectHooksOrderAndFields(t *testing.T) { + hooks := &service.Hooks{ + Poststop: &service.Hook{Run: "echo stopped"}, + Prerun: &service.Hook{ + Run: "npm run setup", + Shell: "bash", + ContinueOnError: true, + Interactive: true, + }, + } + + got := collectHooks(hooks) + + if len(got) != 2 { + t.Fatalf("expected 2 hooks, got %d", len(got)) + } + // prerun sorts before poststop in lifecycle order. + if got[0].Name != "prerun" || got[1].Name != "poststop" { + t.Fatalf("unexpected order: %s, %s", got[0].Name, got[1].Name) + } + + prerun := got[0] + if prerun.Run != "npm run setup" || prerun.Shell != "bash" || !prerun.ContinueOnError || !prerun.Interactive { + t.Errorf("unexpected prerun fields: %+v", prerun) + } + if got[1].Run != "echo stopped" || got[1].Shell != "" { + t.Errorf("unexpected poststop fields: %+v", got[1]) + } +} + +func TestCollectHooksPlatformOverride(t *testing.T) { + hooks := &service.Hooks{ + Prerun: &service.Hook{ + Run: "./setup.sh", + Windows: &service.PlatformHook{ + Run: "setup.ps1", + Shell: "pwsh", + ContinueOnError: boolPtr(true), + }, + Posix: &service.PlatformHook{ + Run: "./setup.sh", + Shell: "bash", + }, + }, + } + + got := collectHooks(hooks) + + if len(got) != 1 { + t.Fatalf("expected 1 hook, got %d", len(got)) + } + win := got[0].Windows + if win == nil || win.Run != "setup.ps1" || win.Shell != "pwsh" || win.ContinueOnError == nil || !*win.ContinueOnError { + t.Errorf("unexpected windows override: %+v", win) + } + posix := got[0].Posix + if posix == nil || posix.Run != "./setup.sh" || posix.Shell != "bash" { + t.Errorf("unexpected posix override: %+v", posix) + } + if posix.ContinueOnError != nil { + t.Errorf("expected nil continueOnError for posix, got %v", *posix.ContinueOnError) + } +} + +func TestToHookOverrideNil(t *testing.T) { + if toHookOverride(nil) != nil { + t.Error("expected nil override for nil platform hook") + } +} + +func TestHooksJSONShape(t *testing.T) { + hooks := &service.Hooks{ + Prerun: &service.Hook{Run: "npm run setup", Shell: "bash"}, + } + + data, err := json.Marshal(collectHooks(hooks)) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded []struct { + Name string `json:"name"` + Run string `json:"run"` + Shell string `json:"shell"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if len(decoded) != 1 || decoded[0].Name != "prerun" || decoded[0].Run != "npm run setup" || decoded[0].Shell != "bash" { + t.Errorf("unexpected json shape: %+v", decoded) + } +} + +func TestShellHelpers(t *testing.T) { + if shellOrDefault("") != "(default)" { + t.Error("empty shell should render as (default)") + } + if shellOrDefault("pwsh") != "pwsh" { + t.Error("explicit shell should be preserved") + } + if shellOrNote("") != "(none)" { + t.Error("empty run should render as (none)") + } + if shellOrNote("echo hi") != "echo hi" { + t.Error("explicit run should be preserved") + } +} + +func TestOverrideSummary(t *testing.T) { + got := overrideSummary(&hookOverride{Run: "setup.ps1", Shell: "pwsh"}) + if got != "setup.ps1 (shell: pwsh)" { + t.Errorf("unexpected summary: %q", got) + } + got = overrideSummary(&hookOverride{Run: "setup.ps1"}) + if got != "setup.ps1" { + t.Errorf("unexpected summary without shell: %q", got) + } +} diff --git a/cli/src/cmd/app/main.go b/cli/src/cmd/app/main.go index abc6493aa..a9b8baf96 100644 --- a/cli/src/cmd/app/main.go +++ b/cli/src/cmd/app/main.go @@ -106,6 +106,7 @@ func main() { commands.NewAddCommand(), commands.NewSupportBundleCommand(), commands.NewGraphCommand(), + commands.NewHooksCommand(), commands.NewMetadataCommand(func() *cobra.Command { return rootCmd }), )