Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,19 @@ so the demo cannot drift from the documented behaviour.
## Exit status

The command reports for itself.
Apart from `-h`, which prints usage on standard output and exits `0`,
`envrun` has statuses only for its own failures, before the command starts,
following the convention of coreutils `env`, `timeout` and `nohup`:

- `125`: `envrun` itself failed — the environment file could not be read,
or no command was given
a flag was not understood, or no command was given
- `126`: the command exists but could not be executed
- `127`: the command could not be found

A command exiting `125` itself is indistinguishable by status alone,
so standard error carries the answer:
every failure of `envrun`'s own is prefixed `envrun failed:`.
A `0` from `-h` carries no such line, having failed at nothing.
The rest, and the Windows divergence, is in
[Exit status and platform scope](docs/exit-status.md).

Expand Down
6 changes: 6 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// Usage:
//
// envrun [-f file] command [argument ...]
// envrun -h
//
// The flags are:
//
Expand All @@ -13,6 +14,10 @@
// The file must exist, named or defaulted:
// a run with no defaults to add does not need envrun.
//
// -h, -help
// Print the usage message on standard output and exit 0,
// without reading the environment or running a command.
//
// The file is read, never sourced — no expansion and no execution — so a value
// reaches the command exactly as written. Its variables are merged under the
// inherited environment, so a name already exported wins over the file.
Expand All @@ -30,6 +35,7 @@
//
// Statuses 125, 126 and 127 report envrun's own failures before the command
// starts, following the convention of coreutils env, timeout and nohup.
// They are the only statuses envrun reports for itself, -h aside.
// A command exiting 125 itself stays distinguishable,
// because envrun names itself on standard error whenever one of these is its own.
// See docs/exit-status.md.
Expand Down
8 changes: 8 additions & 0 deletions docs/adr/002-splitting-the-command-from-the-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,12 @@ It reports through two package-level helpers, `fail(err)` and `note(format, args
writing to `log`'s default logger with the flags cleared.
Two shapes, one destination, no levels.

**That covers diagnostics, not everything envrun writes.**
Output the user *asked for* — `-h`'s usage today, `-version` later —
goes to standard output and carries no prefix,
because it is the command's product rather than a report about it,
and a caller redirecting it wants it apart from the diagnostics.

**The `log` package with its flags cleared, not `slog`.**
That is what is already in place, and what stays:
the prefix lives in the format string, the destination is `log`'s own default,
Expand Down Expand Up @@ -463,6 +469,8 @@ it may name the variable and the lines, never either value.
- **The CLI keeps `log`**, flags cleared, through the two helpers it already has,
emitting plain prefixed lines on standard error rather than structured records.
`slog` is declined and the package-level logger kept.
Amended by #46: that governs diagnostics, and requested output such as `-h`'s
usage goes to standard output instead.
See *Who prints, and how*.

- **Duplicate detection lands with issue #3**, which already asks for it:
Expand Down
10 changes: 7 additions & 3 deletions docs/exit-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ On \*nix there is no `envrun` left once it starts,
so its status, including death by a signal,
reaches the caller unchanged and untranslated.

Apart from `-h`, which prints usage on standard output and exits `0`
without reading the environment or running anything,
`envrun` has statuses only for its own failures, before the command starts.
They follow the convention used by coreutils `env`, `timeout` and `nohup`,
which keeps them out of the range a command is likely to use:

- `125`: `envrun` itself failed — the environment file could not be read,
or no command was given
a flag was not understood, or no command was given
- `126`: the command exists but could not be executed
- `127`: the command could not be found

Expand All @@ -30,8 +32,10 @@ because every failure of `envrun`'s own names the process that failed:
envrun failed: reading .env: open .env: no such file or directory
```

That line is present exactly when the status is `envrun`'s;
where it is absent, the status belongs to the command.
Among `125`, `126` and `127` that line is present exactly when the status is
`envrun`'s; where it is absent, the status belongs to the command.
The qualifier matters because `-h` is envrun's own `0` and carries no such line:
it did not fail, and nothing ran.

`envrun` starts what the operating system can start, and no more:
a file with the execute bit but no shebang and no loadable format reports `126`,
Expand Down
3 changes: 3 additions & 0 deletions env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import (
)

const (
// AppName is the name of the application, regardless of its invocation.
AppName = "envrun"

// commentRxS matches comment lines.
commentRxS = `^[\s]*#`
// nameRxS is much tighter than Posix, which accepts anything but NUL and '=',
Expand Down
2 changes: 1 addition & 1 deletion env/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (
// is also compiled as a standalone program — by pkg.go.dev's Run button, say —
// where a relative path would resolve against somewhere else entirely.
func writeEnv(body string) (path string, remove func()) {
dir, err := os.MkdirTemp("", "envrun")
dir, err := os.MkdirTemp("", env.AppName)
if err != nil {
log.Fatal(err)
}
Expand Down
80 changes: 73 additions & 7 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"flag"
"fmt"
"io"
iofs "io/fs"
"log"
"os"
Expand All @@ -18,21 +19,73 @@ import (
//
// It resolves nothing and opens nothing: finding the file is env.Load's job,
// and -f names the one candidate it is to consider.
func parseArgs(args []string) (string, []string, error) {
//
// stdW is used by -h, errW by the error paths.
func parseArgs(args []string, stdW, errW io.Writer) (string, []string, error) {
if len(args) < 2 {
return "", nil, errors.New("need at least a command to run")
}
fs := flag.NewFlagSet(args[0], flag.ContinueOnError)

// The set is named for the command rather than for args[0],
// so usage carries a stable name instead of the path the binary was
// reached through, which under `go run` is a build-cache directory.
fs := flag.NewFlagSet(env.AppName, flag.ContinueOnError)
// Discarded so that flag's own reporting does not land beside envrun's.
// It reports a rejected flag and returns the error, where envrun needs the
// line attributed; the message survives in the error, and is written below.
fs.SetOutput(io.Discard)
inName := fs.String("f", env.DefaultPath, "The file from which to read the environment variables")

// Declared rather than left to flag.
// Declaring keeps flag from writing usage of its own, which it would send to one destination,
// for both a help request and a parse failure — and those need different ones.
showHelp := fs.Bool("h", false, "Print this message on standard output and exit")
fs.BoolVar(showHelp, "help", false, "Alias for -h")

// Prepend to flag's own rendering rather than replacing it:
// PrintDefaults cannot know about the command and arguments.
fs.Usage = func() {
fmt.Fprint(fs.Output(),
"Usage: envrun [-f file] command [argument ...]\n"+
" envrun -h\n\nThe flags are:\n")
fs.PrintDefaults()
}

if err := fs.Parse(args[1:]); err != nil {
return "", nil, fmt.Errorf("parsing flags: %w", err)
// Reported here rather than by realMain, because usage follows the
// message and only this function holds a set that can render it.
fail(fmt.Errorf("parsing flags: %w", err))
fs.SetOutput(errW)
fs.Usage()
return "", nil, errReported
}

// An exclusive flag says what envrun does instead of running a command,
// so it resolves before the operand check below,
// and before realMain reads the environment: neither applies to it.
//
// -h dominates: where it appears, every other flag and the operand are
// ignored, which is what sort, curl and python3 do and what a caller
// asking what this program is will least be surprised by.
if *showHelp {
fs.SetOutput(stdW)
fs.Usage()
return "", nil, flag.ErrHelp
}

if len(fs.Args()) == 0 {
return "", nil, errors.New("no command to run")
}
return *inName, fs.Args(), nil
}

// errReported marks a failure whose message has already reached the caller,
// so realMain returns its status without saying it a second time.
//
// Named for the property rather than the case: flag reports a rejected command
// line itself, and anything else that reports before returning can reuse this.
var errReported = errors.New("already reported")

// exitStatus classifies a failure of envrun's own into the status the README documents.
//
// It never sees a command's own status:
Expand All @@ -56,6 +109,13 @@ func exitStatus(err error) int {
}
}

// A line starting with failPrefix means the status is envrun's; notePrefix means it is not.
// docs/exit-status.md builds attribution on the difference.
const (
failPrefix = env.AppName + " failed: "
notePrefix = env.AppName + ": "
)

// fail reports a failure of envrun's own, naming envrun as the process that failed.
//
// Every such line begins with the same three words,
Expand All @@ -68,7 +128,7 @@ func exitStatus(err error) int {
// so the words are written in one place rather than at each call site,
// where a message would eventually be phrased differently.
func fail(err error) {
log.Printf("envrun failed: %v", err)
log.Printf(failPrefix+"%v", err)
}

// note reports something envrun saw without failing at it.
Expand All @@ -79,7 +139,7 @@ func fail(err error) {
// It still names envrun,
// since nothing else on standard error should be taken for the command's own output.
func note(format string, args ...any) {
log.Printf("envrun: "+format, args...)
log.Printf(notePrefix+format, args...)
}

// reportNotes prints the non-fatal findings a [env.Result] carries.
Expand All @@ -104,8 +164,14 @@ func reportNotes(notes []env.Note) {
// env.Load hands back what it saw, and this is the only party that knows the
// output contract the README documents. See ADR-002.
func realMain(args []string) int {
path, toRun, err := parseArgs(args)
if err != nil {
path, toRun, err := parseArgs(args, os.Stdout, os.Stderr)
switch {
// Help is not a failure: it wrote what was asked for and nothing ran.
case errors.Is(err, flag.ErrHelp):
return 0
case errors.Is(err, errReported):
return ExitEnvrun
case err != nil:
fail(err)
return ExitEnvrun
}
Expand Down
Loading