diff --git a/README.md b/README.md index 2043c8b..9897a7b 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ go run ./cmd/releasekit version - [Contributor guide](docs/development.md): orientation, validation, and selective policy reading. - [CLI contract](docs/cli.md): the current `version` command and its output. +- [Configuration](docs/config.md): profile fields and child-command settings. - [Runtime version guidance](docs/consumers/runtime-version.md): how applications read their embedded version through `buildinfo`. - [Internal package inventory](docs/internal/overview.md): implemented internal diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go new file mode 100644 index 0000000..a1ab84f --- /dev/null +++ b/cmd/releasekit/internal/cli/config.go @@ -0,0 +1,117 @@ +// Package cli owns command parsing, profile settings, and child-command +// adaptation for the ReleaseKit executable. +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "gitea.maximumdirect.net/eric/ReleaseKit/internal/process" +) + +const Usage = "usage: releasekit version" + +type Command string + +const VersionCommand Command = "version" + +// Invocation contains parsed command settings owned by the CLI adapter. +type Invocation struct { + Command Command +} + +// Parse accepts only commands that have been implemented by the executable. +func Parse(args []string) (Invocation, error) { + if len(args) == 1 && args[0] == string(VersionCommand) { + return Invocation{Command: VersionCommand}, nil + } + if len(args) == 0 { + return Invocation{}, errors.New("a command is required") + } + return Invocation{}, fmt.Errorf("unknown command %q", args[0]) +} + +// CheckCommand contains one direct child-process invocation from a profile. +type CheckCommand struct { + Args []string `json:"args"` + Env map[string]string `json:"env,omitempty"` +} + +// Settings contains the product, remote, and check commands read from a +// releasekit.json profile. +type Settings struct { + Product string `json:"product"` + Remote string `json:"remote,omitempty"` + Checks []CheckCommand `json:"checks,omitempty"` +} + +// LoadSettings reads a profile path relative to repoDir unless it is absolute. +// An empty configPath selects /releasekit.json. +func LoadSettings(repoDir, configPath string) (Settings, error) { + resolvedRepo, err := filepath.Abs(repoDir) + if err != nil { + return Settings{}, fmt.Errorf("resolve repository directory %q: %w", repoDir, err) + } + if configPath == "" { + configPath = "releasekit.json" + } + if !filepath.IsAbs(configPath) { + configPath = filepath.Join(resolvedRepo, configPath) + } + + file, err := os.Open(configPath) + if err != nil { + return Settings{}, fmt.Errorf("open profile %q: %w", configPath, err) + } + defer file.Close() + + var settings Settings + decoder := json.NewDecoder(file) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&settings); err != nil { + return Settings{}, fmt.Errorf("decode profile %q: %w", configPath, err) + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return Settings{}, fmt.Errorf("decode profile %q: multiple JSON values", configPath) + } + return Settings{}, fmt.Errorf("decode profile %q: %w", configPath, err) + } + + if strings.TrimSpace(settings.Product) == "" { + return Settings{}, fmt.Errorf("profile %q: product is required", configPath) + } + if settings.Remote == "" { + settings.Remote = "origin" + } + for i, check := range settings.Checks { + if len(check.Args) == 0 || check.Args[0] == "" { + return Settings{}, fmt.Errorf("profile %q: checks[%d].args must include a command", configPath, i) + } + } + return settings, nil +} + +// RunChecks runs configured argument vectors in repoDir. Each command receives +// its own environment overlay and inherits the remaining process environment. +func RunChecks(ctx context.Context, repoDir string, checks []CheckCommand, stdout, stderr io.Writer) error { + for i, check := range checks { + if err := process.Run(ctx, process.Invocation{ + Args: check.Args, + Dir: repoDir, + Env: check.Env, + Stdout: stdout, + Stderr: stderr, + }); err != nil { + return fmt.Errorf("run check %d: %w", i+1, err) + } + } + return nil +} diff --git a/cmd/releasekit/internal/cli/config_test.go b/cmd/releasekit/internal/cli/config_test.go new file mode 100644 index 0000000..5762df3 --- /dev/null +++ b/cmd/releasekit/internal/cli/config_test.go @@ -0,0 +1,172 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParse(t *testing.T) { + invocation, err := Parse([]string{"version"}) + if err != nil { + t.Fatal(err) + } + if invocation.Command != VersionCommand { + t.Fatalf("Parse() command = %q, want %q", invocation.Command, VersionCommand) + } + if _, err := Parse(nil); err == nil || !strings.Contains(err.Error(), "command is required") { + t.Fatalf("Parse(nil) error = %v, want missing-command error", err) + } + if _, err := Parse([]string{"publish"}); err == nil || !strings.Contains(err.Error(), "unknown command") { + t.Fatalf("Parse(publish) error = %v, want unknown-command error", err) + } +} + +func TestLoadSettingsResolvesPathsAndPreservesArguments(t *testing.T) { + repoDir := filepath.Join(t.TempDir(), "repository with spaces") + profileDir := filepath.Join(repoDir, "profiles with spaces") + if err := os.MkdirAll(profileDir, 0o700); err != nil { + t.Fatal(err) + } + want := Settings{ + Product: "Example Product", + Remote: "upstream", + Checks: []CheckCommand{{ + Args: []string{"sh", "scripts/check release.sh", "two words"}, + Env: map[string]string{"RELEASE_MODE": "candidate check"}, + }}, + } + contents, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(profileDir, "release profile.json"), contents, 0o600); err != nil { + t.Fatal(err) + } + + got, err := LoadSettings(repoDir, filepath.Join("profiles with spaces", "release profile.json")) + if err != nil { + t.Fatal(err) + } + if got.Product != want.Product || got.Remote != want.Remote || len(got.Checks) != 1 { + t.Fatalf("LoadSettings() = %#v, want product, remote, and one check", got) + } + if got.Checks[0].Args[1] != "scripts/check release.sh" || got.Checks[0].Args[2] != "two words" { + t.Fatalf("check arguments were changed: %#v", got.Checks[0].Args) + } + if got.Checks[0].Env["RELEASE_MODE"] != "candidate check" { + t.Fatalf("check environment = %#v", got.Checks[0].Env) + } +} + +func TestLoadSettingsDefaultsRemoteAndConfigPath(t *testing.T) { + repoDir := t.TempDir() + contents := `{"product":"Example"}` + profilePath := filepath.Join(repoDir, "releasekit.json") + if err := os.WriteFile(profilePath, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + path string + }{ + {name: "default path"}, + {name: "absolute path", path: profilePath}, + } { + t.Run(test.name, func(t *testing.T) { + settings, err := LoadSettings(repoDir, test.path) + if err != nil { + t.Fatal(err) + } + if settings.Remote != "origin" { + t.Fatalf("default remote = %q, want origin", settings.Remote) + } + }) + } +} + +func TestExampleProfileLoads(t *testing.T) { + repoDir, err := filepath.Abs(filepath.Join("..", "..", "..", "..")) + if err != nil { + t.Fatal(err) + } + settings, err := LoadSettings(repoDir, filepath.Join("examples", "releasekit.json")) + if err != nil { + t.Fatal(err) + } + if settings.Product != "Example" || settings.Remote != "origin" || len(settings.Checks) != 1 { + t.Fatalf("example settings = %#v", settings) + } +} + +func TestLoadSettingsRejectsInvalidProfiles(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + {name: "missing product", content: `{}`, want: "product is required"}, + {name: "unknown top-level field", content: `{"product":"Example","delivery":{}}`, want: "unknown field"}, + {name: "unsupported build field", content: `{"product":"Example","build":{}}`, want: "unknown field"}, + {name: "unknown command field", content: `{"product":"Example","checks":[{"args":["true"],"shell":true}]}`, want: "unknown field"}, + {name: "command is required", content: `{"product":"Example","checks":[{"args":[]}]}`, want: "must include a command"}, + {name: "multiple JSON values", content: `{"product":"Example"}{"product":"Other"}`, want: "multiple JSON values"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repoDir := t.TempDir() + path := filepath.Join(repoDir, "profile.json") + if err := os.WriteFile(path, []byte(test.content), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadSettings(repoDir, path); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("LoadSettings() error = %v, want text %q", err, test.want) + } + }) + } +} + +func TestRunChecksUsesConfiguredArgumentsEnvironmentAndDirectory(t *testing.T) { + workdir := filepath.Join(t.TempDir(), "repo with spaces") + if err := os.Mkdir(workdir, 0o700); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + err := RunChecks(context.Background(), workdir, []CheckCommand{{ + Args: []string{helperExecutable(t), "-test.run=TestConfiguredCommandHelper", "--", "argument with spaces"}, + Env: map[string]string{"RELEASEKIT_CONFIGURED_HELPER": "1", "RELEASEKIT_CONFIGURED_VALUE": "selected value"}, + }}, &stdout, &stderr) + if err != nil { + t.Fatal(err) + } + want := fmt.Sprintf("%s|argument with spaces|selected value", workdir) + if stdout.String() != want { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } +} + +func TestConfiguredCommandHelper(t *testing.T) { + if os.Getenv("RELEASEKIT_CONFIGURED_HELPER") != "1" { + return + } + workdir, err := os.Getwd() + if err != nil { + os.Exit(9) + } + fmt.Fprintf(os.Stdout, "%s|%s|%s", workdir, os.Args[len(os.Args)-1], os.Getenv("RELEASEKIT_CONFIGURED_VALUE")) + os.Exit(0) +} + +func helperExecutable(t *testing.T) string { + t.Helper() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + return executable +} diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index 361bb1a..0932c55 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -6,6 +6,7 @@ import ( "os" "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" + "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" ) func main() { @@ -13,11 +14,16 @@ func main() { } func run(args []string, stdout, stderr io.Writer) int { - if len(args) == 1 && args[0] == "version" { + invocation, err := cli.Parse(args) + if err != nil { + fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage) + return 2 + } + + switch invocation.Command { + case cli.VersionCommand: fmt.Fprintf(stdout, "releasekit %s\n", buildinfo.Version()) return 0 } - - fmt.Fprintln(stderr, "usage: releasekit version") return 2 } diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index 82f4abb..468b613 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -29,7 +29,7 @@ func TestUnknownCommand(t *testing.T) { if stdout.Len() != 0 { t.Fatalf("unknown command wrote to stdout: %q", stdout.String()) } - if want := "usage: releasekit version\n"; stderr.String() != want { + if want := "unknown command \"publish\"\nusage: releasekit version\n"; stderr.String() != want { t.Fatalf("usage output = %q, want %q", stderr.String(), want) } } diff --git a/docs/cli.md b/docs/cli.md index 898c6ad..b65cbef 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -9,5 +9,5 @@ go run ./cmd/releasekit version It prints `releasekit ` followed by a newline and exits with status 0. The version comes from [`buildinfo.Version`](consumers/runtime-version.md). -Any other argument sequence prints `usage: releasekit version` to standard -error and exits with status 2. +Any other argument sequence prints an error message followed by +`usage: releasekit version` to standard error and exits with status 2. diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..8f331be --- /dev/null +++ b/docs/config.md @@ -0,0 +1,27 @@ +# Configuration + +ReleaseKit reads a JSON profile from `/releasekit.json` by default. +The repository directory defaults to the current directory. A relative profile +path is resolved from that repository directory; an absolute path is used as-is. + +The current profile fields are: + +| Field | Required | Behavior | +| --- | --- | --- | +| `product` | Yes | Product name used by release operations. | +| `remote` | No | Selected Git remote; defaults to `origin`. | +| `checks` | No | Ordered child commands, each with an argument vector and optional environment map. | + +Each check requires a nonempty `args` array whose first element names the +executable. Arguments are passed directly to the child process; ReleaseKit does +not parse a shell command string. Select a shell explicitly when a check needs +shell syntax. Each `env` map overlays the current environment for that child. +The overlay does not modify the ReleaseKit process environment. + +Unknown fields are rejected so a setting cannot be silently ignored. Build and +delivery fields will be added when those operations are implemented. Profile +loading and child-command adaptation are implemented; no CLI operation consumes +the profile yet. + +See the [profile example](../examples/releasekit.json). The +[CLI contract](cli.md) lists the currently available command. diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 94cb63d..be5083d 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -12,7 +12,12 @@ Implemented internal packages: release builder. - `internal/apicheck` checks compiled exports with `go/types` and checks import direction. Run it with `go run ./internal/apicheck` from the repository root. +- `internal/process` runs cancellable child commands with per-child environment + overlays. +- `cmd/releasekit/internal/cli` parses implemented commands, loads strict JSON + profiles, and adapts configured checks to the process runner. -The executable currently imports only the runtime facade. Release orchestration, -process execution, build, Git, and Gitea implementation packages are introduced -as their responsibilities are implemented. +The executable controller uses the runtime facade and the command-owned CLI +adapter. The adapter uses the process runner and imports neither public facade. +Release orchestration, build, Git, and Gitea implementation packages are +introduced as their responsibilities are implemented. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 32cded3..1d05f69 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -510,7 +510,7 @@ checker command and internal package inventory to the canonical developer docs. ## Stage 4: Implement profile loading and command adaptation -Status: Pending. Depends on Stage 3. +Status: Complete. Depends on Stage 3. 1. Implement JSON product, remote, and command settings under an internal CLI adapter. Add strict field decoding, directory resolution, required-value @@ -527,6 +527,15 @@ selected environment values reach the child without changing process globals; missing executables, invalid configuration, and cancellation report useful errors. Tests use controlled local child commands and temporary directories. +Completion evidence: added strict profile decoding, repository-relative and +absolute profile resolution, required product and command diagnostics, and +direct check argument adaptation. Added a cancellable `os/exec` runner with +working directory, inherited environment plus child-only overlays, stdout, +stderr, and preserved exit errors. Tests cover spaced paths and arguments, +environment isolation, missing executables, exit failures, cancellation, and +invalid profiles. Added a profile example and canonical CLI, configuration, and +internal package documentation. + ## Stage 5: Capture and validate one local candidate Status: Pending. Depends on Stage 4. diff --git a/examples/releasekit.json b/examples/releasekit.json new file mode 100644 index 0000000..b974019 --- /dev/null +++ b/examples/releasekit.json @@ -0,0 +1,12 @@ +{ + "product": "Example", + "remote": "origin", + "checks": [ + { + "args": ["go", "test", "-count=1", "./..."], + "env": { + "RELEASE_MODE": "candidate" + } + } + ] +} diff --git a/internal/apicheck/main.go b/internal/apicheck/main.go index 0eb76d1..eac487c 100644 --- a/internal/apicheck/main.go +++ b/internal/apicheck/main.go @@ -108,7 +108,7 @@ func checkImportDirections(packages []listedPackage) error { var violations []string for _, pkg := range packages { - if !strings.HasPrefix(pkg.ImportPath, modulePath+"/internal/") { + if !isInternalPackage(pkg.ImportPath) { continue } for _, imported := range pkg.Imports { @@ -149,6 +149,11 @@ func checkImportDirections(packages []listedPackage) error { return errors.New(strings.Join(violations, "\n")) } +func isInternalPackage(path string) bool { + return strings.HasPrefix(path, modulePath+"/internal/") || + strings.HasPrefix(path, modulePath+"/") && strings.Contains(strings.TrimPrefix(path, modulePath), "/internal/") +} + func forbiddenRuntimeDependency(path string) bool { for _, part := range strings.Split(strings.ToLower(path), "/") { switch part { diff --git a/internal/apicheck/main_test.go b/internal/apicheck/main_test.go index bf7d941..8d54be5 100644 --- a/internal/apicheck/main_test.go +++ b/internal/apicheck/main_test.go @@ -110,10 +110,14 @@ func TestActualRepositoryBoundaries(t *testing.T) { func TestInternalPackagesCannotImportPublicFacades(t *testing.T) { packages := []listedPackage{ {ImportPath: modulePath + "/internal/operations", Imports: []string{modulePath}}, + {ImportPath: modulePath + "/cmd/releasekit/internal/cli", Imports: []string{modulePath + "/buildinfo"}}, {ImportPath: modulePath}, + {ImportPath: modulePath + "/buildinfo"}, } err := checkImportDirections(packages) - if err == nil || !strings.Contains(err.Error(), "internal package "+modulePath+"/internal/operations imports public or command package "+modulePath) { + if err == nil || + !strings.Contains(err.Error(), "internal package "+modulePath+"/internal/operations imports public or command package "+modulePath) || + !strings.Contains(err.Error(), "internal package "+modulePath+"/cmd/releasekit/internal/cli imports public or command package "+modulePath+"/buildinfo") { t.Fatalf("checkImportDirections() error = %v, want internal-to-facade violation", err) } } diff --git a/internal/process/process.go b/internal/process/process.go new file mode 100644 index 0000000..2f12bd3 --- /dev/null +++ b/internal/process/process.go @@ -0,0 +1,60 @@ +// Package process runs child commands for ReleaseKit's internal adapters. +package process + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "sort" +) + +// Invocation describes one child process. Environment values overlay the +// current process environment for this child only. +type Invocation struct { + Args []string + Dir string + Env map[string]string + Stdout io.Writer + Stderr io.Writer +} + +// Run starts the command in the requested directory and waits for it to exit. +// Errors preserve the underlying exec error, including *exec.ExitError. +func Run(ctx context.Context, invocation Invocation) error { + if len(invocation.Args) == 0 || invocation.Args[0] == "" { + return errors.New("process invocation requires a command") + } + + command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...) + command.Dir = invocation.Dir + if len(invocation.Env) != 0 { + command.Env = append(os.Environ(), environmentEntries(invocation.Env)...) + } + command.Stdout = invocation.Stdout + command.Stderr = invocation.Stderr + + if err := command.Run(); err != nil { + if contextErr := ctx.Err(); contextErr != nil { + return fmt.Errorf("run %q: %w", invocation.Args[0], contextErr) + } + return fmt.Errorf("run %q: %w", invocation.Args[0], err) + } + return nil +} + +func environmentEntries(environment map[string]string) []string { + keys := make([]string, 0, len(environment)) + for key := range environment { + keys = append(keys, key) + } + sort.Strings(keys) + + entries := make([]string, 0, len(keys)) + for _, key := range keys { + entries = append(entries, key+"="+environment[key]) + } + return entries +} diff --git a/internal/process/process_test.go b/internal/process/process_test.go new file mode 100644 index 0000000..a9cdb4a --- /dev/null +++ b/internal/process/process_test.go @@ -0,0 +1,145 @@ +package process + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRunPassesArgumentsDirectoryAndEnvironment(t *testing.T) { + workdir := filepath.Join(t.TempDir(), "directory with spaces") + if err := os.Mkdir(workdir, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("RELEASEKIT_PROCESS_OVERLAY", "parent") + t.Setenv("RELEASEKIT_PROCESS_INHERITED", "inherited") + + var stdout, stderr bytes.Buffer + err := Run(context.Background(), Invocation{ + Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "argument with spaces", "success"}, + Dir: workdir, + Env: map[string]string{ + "RELEASEKIT_PROCESS_HELPER": "1", + "RELEASEKIT_PROCESS_OVERLAY": "child", + }, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatal(err) + } + want := fmt.Sprintf("%s|argument with spaces|child|inherited", workdir) + if stdout.String() != want { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } + if stderr.String() != "child stderr\n" { + t.Fatalf("stderr = %q, want %q", stderr.String(), "child stderr\n") + } + if got := os.Getenv("RELEASEKIT_PROCESS_OVERLAY"); got != "parent" { + t.Fatalf("parent environment changed: got %q, want %q", got, "parent") + } +} + +func TestRunPreservesExitError(t *testing.T) { + err := Run(context.Background(), Invocation{ + Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "exit"}, + Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"}, + }) + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 7 { + t.Fatalf("Run() error = %v, want exit code 7", err) + } +} + +func TestRunReportsMissingExecutable(t *testing.T) { + err := Run(context.Background(), Invocation{Args: []string{"releasekit-command-that-does-not-exist"}}) + if err == nil || !strings.Contains(err.Error(), "releasekit-command-that-does-not-exist") { + t.Fatalf("Run() error = %v, want missing executable diagnostic", err) + } +} + +func TestRunReturnsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + started := make(chan struct{}, 1) + invocation := Invocation{ + Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "wait"}, + Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"}, + Stdout: writerFunc(func([]byte) (int, error) { + select { + case started <- struct{}{}: + default: + } + return len("started\n"), nil + }), + } + + result := make(chan error, 1) + go func() { result <- Run(ctx, invocation) }() + select { + case <-started: + cancel() + case <-time.After(5 * time.Second): + t.Fatal("child did not start") + } + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context cancellation", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Run() did not return after cancellation") + } +} + +func TestProcessHelper(t *testing.T) { + if os.Getenv("RELEASEKIT_PROCESS_HELPER") != "1" { + return + } + args := os.Args + if len(args) < 2 { + os.Exit(8) + } + mode := args[len(args)-1] + switch mode { + case "success": + workdir, err := os.Getwd() + if err != nil { + os.Exit(9) + } + fmt.Fprintf(os.Stdout, "%s|%s|%s|%s", workdir, args[len(args)-2], os.Getenv("RELEASEKIT_PROCESS_OVERLAY"), os.Getenv("RELEASEKIT_PROCESS_INHERITED")) + fmt.Fprintln(os.Stderr, "child stderr") + os.Exit(0) + case "exit": + os.Exit(7) + case "wait": + fmt.Fprintln(os.Stdout, "started") + for { + time.Sleep(time.Second) + } + default: + os.Exit(10) + } +} + +type writerFunc func([]byte) (int, error) + +func (write writerFunc) Write(p []byte) (int, error) { + return write(p) +} + +func helperExecutable(t *testing.T) string { + t.Helper() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + return executable +}