Verified on a Windows 11 VM over WinRM: doctor, SAPI synthesis, Edge, oto/WASAPI playback, autostart, mute file, wrap exit codes, doctor --probe and toast mode all work. Three findings fixed: - The autostarted daemon died with the WinRM shell: Windows session hosts (WinRM, SSH, CI runners) put children into a job object that is terminated with the session. Spawn now starts the daemon with CREATE_BREAKAWAY_FROM_JOB first and falls back to a start without it when the job forbids breakaway. Confirmed: the daemon now outlives the WinRM shell and `talk daemon stop` still ends it. - `talk mute` without a daemon printed "daemon call failed (<nil>)": the outer err was reported instead of the call's. Now "no daemon (<reason>); writing the mute file directly". - `talk wrap` printed "command did not exit cleanly" for every non-zero exit code. The note is now reserved for commands that produced no exit code of their own (not found, signal); a plain failure is carried by the announcement and the exit code alone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
190 lines
7.0 KiB
Go
190 lines
7.0 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
|
"code.m3ta.dev/m3tam3re/talk/internal/langdetect"
|
|
)
|
|
|
|
// exitNotFound is the shell convention for "command not found".
|
|
const exitNotFound = 127
|
|
|
|
// runWrap is the wrap entry point: `talk wrap -- <command> [args …]` runs
|
|
// the command (stdin/stdout/stderr passed through; stdout belongs to the
|
|
// command alone), measures wall time and announces the result afterwards:
|
|
// "<label> succeeded. Duration: X." or "<label> failed, code N." (label =
|
|
// config `wrap.label` or the command's base name; localized de/en by
|
|
// language detection on the label, falling back to the configured lang).
|
|
// The announcement takes the same path as say: daemon (enqueue, non-
|
|
// blocking; --wait honoured; autostart), without a daemon directly. wrap
|
|
// inherits the say flags and passes them on.
|
|
//
|
|
// Exit code policy: wrap ALWAYS exits with the command's code (talk errors
|
|
// are not command errors). If the announcement fails, there is a warning on
|
|
// stderr and the command's code stands. Usage errors of talk itself (no
|
|
// command, unknown flags, invalid --lang) fail BEFORE the command starts,
|
|
// so it does not run at all. Only configuration errors (broken config.toml
|
|
// or env) surface after the run: the command has run, the announcement is
|
|
// skipped with a warning.
|
|
//
|
|
// The positional arguments are the command line (command + args); the "--"
|
|
// itself was consumed by parseInterspersed, everything after it came
|
|
// through untouched.
|
|
func runWrap(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(stderr, "talk wrap: no command given; usage: talk wrap [flags] -- <command> [args …]")
|
|
return exitUsage
|
|
}
|
|
if code, bad := validateSayOptions(opts, stderr); bad {
|
|
return code
|
|
}
|
|
|
|
start := time.Now()
|
|
runErr := execChild(args, stdin, stdout, stderr)
|
|
duration := time.Since(start)
|
|
code := childExitCode(runErr)
|
|
if runErr != nil && !isPlainExit(runErr) {
|
|
// Binary not found or killed by a signal: the command never produced
|
|
// an exit code of its own, so say why wrap exits with the one it
|
|
// does. A plain non-zero exit code needs no note; the announcement
|
|
// and the exit code carry it.
|
|
fmt.Fprintf(stderr, "talk wrap: command did not exit cleanly: %v\n", runErr)
|
|
}
|
|
|
|
cfg, err := config.Load(config.Options{Getenv: getenv})
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "talk wrap: announcement skipped; config: %v (the command's exit code %d stands)\n", err, code)
|
|
return code
|
|
}
|
|
|
|
label := wrapLabel(cfg.Wrap.Label, args[0])
|
|
lang := opts.lang
|
|
if lang == "" {
|
|
lang = langdetect.Detect(label, cfg.Lang)
|
|
}
|
|
announceWrap(cfg, announcementText(label, code, duration, lang), opts, code, stderr)
|
|
return code
|
|
}
|
|
|
|
// announceWrap delivers the wrap announcement the same way say does
|
|
// (announce: daemon, autostart if needed, otherwise direct). The
|
|
// announcement machinery NEVER overrides the command's exit code: if it
|
|
// fails, there is a warning on stderr. Information lines of the
|
|
// announcement (item ID, muted, discarded) go to stderr for wrap: stdout
|
|
// belongs to the (already finished) command, so its pipes and redirects
|
|
// stay clean.
|
|
func announceWrap(cfg config.Config, text string, opts *sayOptions, commandCode int, stderr io.Writer) {
|
|
if announce(cfg, text, opts, stderr, stderr) != exitOK {
|
|
fmt.Fprintf(stderr, "talk wrap: announcement failed; the command's exit code %d stands\n", commandCode)
|
|
}
|
|
}
|
|
|
|
// execChild starts argv as a child process and waits for it. stdin/stdout/
|
|
// stderr are passed through unchanged: the command owns the console, talk
|
|
// does not interfere (the announcement goes out only AFTER the command has
|
|
// ended). Plain os/exec without platform-specific calls: runs unchanged on
|
|
// Windows, macOS and Linux.
|
|
func execChild(argv []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
|
cmd := exec.Command(argv[0], argv[1:]...)
|
|
cmd.Stdin = stdin
|
|
cmd.Stdout = stdout
|
|
cmd.Stderr = stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
// childExitCode translates the child's Run result into the exit code wrap
|
|
// itself exits with (pass-through contract):
|
|
//
|
|
// - normal exit: the command's exit code (0 = success).
|
|
// - killed by a signal: normalized to 1, portably. The Unix convention
|
|
// 128+signal would need syscall.WaitStatus (Go reports signal deaths as
|
|
// ExitCode -1) and would no longer cross-compile freely; the
|
|
// announcement deliberately names the same normalized code.
|
|
// - start failed (binary not found/not executable): 127, the shell
|
|
// convention; the command failed without ever running.
|
|
func childExitCode(runErr error) int {
|
|
if runErr == nil {
|
|
return 0
|
|
}
|
|
var ee *exec.ExitError
|
|
if errors.As(runErr, &ee) {
|
|
if c := ee.ExitCode(); c >= 0 {
|
|
return c
|
|
}
|
|
return exitFailure
|
|
}
|
|
return exitNotFound
|
|
}
|
|
|
|
// isPlainExit reports whether runErr is just a non-zero exit code of a
|
|
// command that ran and exited normally.
|
|
func isPlainExit(runErr error) bool {
|
|
var ee *exec.ExitError
|
|
return errors.As(runErr, &ee) && ee.ExitCode() >= 0
|
|
}
|
|
|
|
// wrapLabel resolves the announcement label: config `wrap.label` (env
|
|
// override TALK_WRAP_LABEL) beats the command's base name
|
|
// (`/usr/bin/make` → "make").
|
|
func wrapLabel(cfgLabel, command string) string {
|
|
if cfgLabel != "" {
|
|
return cfgLabel
|
|
}
|
|
return filepath.Base(command)
|
|
}
|
|
|
|
// formatDuration renders wall time for humans, localized de/en: "3 Minuten
|
|
// 42 Sekunden" / "3 minutes 42 seconds". Rounded to whole seconds (half
|
|
// up); zero parts are dropped ("1 Minute"), only an overall zero keeps
|
|
// "0 Sekunden". Singulars are declined ("1 Sekunde", "1 minute").
|
|
// Anything over an hour names hours too.
|
|
func formatDuration(d time.Duration, lang string) string {
|
|
type unit struct{ singular, plural string }
|
|
units := []unit{{"hour", "hours"}, {"minute", "minutes"}, {"second", "seconds"}}
|
|
if lang == "de" {
|
|
units = []unit{{"Stunde", "Stunden"}, {"Minute", "Minuten"}, {"Sekunde", "Sekunden"}}
|
|
}
|
|
secs := int64(d.Round(time.Second) / time.Second)
|
|
n := []int64{secs / 3600, secs / 60 % 60, secs % 60}
|
|
var parts []string
|
|
for i, u := range units {
|
|
if n[i] == 0 {
|
|
continue
|
|
}
|
|
word := u.plural
|
|
if n[i] == 1 {
|
|
word = u.singular
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%d %s", n[i], word))
|
|
}
|
|
if len(parts) == 0 {
|
|
return fmt.Sprintf("0 %s", units[2].plural)
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// announcementText builds the wrap announcement: success "<label>
|
|
// succeeded. Duration: X." / failure "<label> failed, code N.", in German
|
|
// "<label> erfolgreich. Dauer: X." / "<label> fehlgeschlagen, Code N.". The
|
|
// language comes from detection on the label (or --lang); anything but
|
|
// "de" speaks English.
|
|
func announcementText(label string, code int, d time.Duration, lang string) string {
|
|
if lang == "de" {
|
|
if code == 0 {
|
|
return fmt.Sprintf("%s erfolgreich. Dauer: %s.", label, formatDuration(d, lang))
|
|
}
|
|
return fmt.Sprintf("%s fehlgeschlagen, Code %d.", label, code)
|
|
}
|
|
if code == 0 {
|
|
return fmt.Sprintf("%s succeeded. Duration: %s.", label, formatDuration(d, lang))
|
|
}
|
|
return fmt.Sprintf("%s failed, code %d.", label, code)
|
|
}
|