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
13 changes: 12 additions & 1 deletion cmd/flashduty/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"path/filepath"
"runtime/debug"
"strings"

Expand All @@ -29,7 +30,17 @@ func main() {
date = "unknown"
}
cli.SetVersionInfo(version, commit, date)
if err := cli.Execute(); err != nil {
// The CLI names itself after the command word the user typed, so a copy
// installed under another name (install.sh INSTALLED_NAME) shows that name
// in help, errors and completion scripts.
name := filepath.Base(os.Args[0])
if ext := filepath.Ext(name); strings.EqualFold(ext, ".exe") {
name = strings.TrimSuffix(name, ext)
}
if name == "" || name == "." || name == string(filepath.Separator) {
name = "flashduty"
}
if err := cli.Execute(name); err != nil {
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
os.Exit(1)
}
Expand Down
13 changes: 11 additions & 2 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Environment variables:
# FLASHDUTY_VERSION - specific version to install (e.g. "v0.1.2")
# FLASHDUTY_INSTALL_DIR - install directory (default: $HOME\.flashduty\bin)
# INSTALLED_NAME - installed command name (default: flashduty)
# MIRROR_URL - fetch release assets from this https mirror prefix.
# Default: https://static.flashcat.cloud/flashduty-cli.
# The mirror must replicate
Expand All @@ -17,7 +18,8 @@ $ErrorActionPreference = "Stop"

$Repo = "flashcatcloud/flashduty-cli"
$Binary = "flashduty-cli.exe"
$InstalledName = "flashduty.exe"
$CommandName = if ($env:INSTALLED_NAME) { $env:INSTALLED_NAME -replace '\.exe$', '' } else { "flashduty" }
$InstalledName = "$CommandName.exe"

# By default release downloads are fetched from the Flashcat CDN. Set MIRROR_URL
# to another prefix to override, or to an empty string to force GitHub fallback.
Expand Down Expand Up @@ -153,6 +155,13 @@ try {
}

$DestPath = Join-Path $InstallDir $InstalledName
if (Test-Path $DestPath) {
# A running .exe can't be overwritten but can be renamed: move it aside
# so `update` can replace the binary it is running from.
$OldPath = "$DestPath.old"
Remove-Item -Path $OldPath -Force -ErrorAction SilentlyContinue
Move-Item -Path $DestPath -Destination $OldPath -Force
}
Move-Item -Path $BinaryPath -Destination $DestPath -Force

Write-Info "Installed to $DestPath"
Expand All @@ -165,7 +174,7 @@ try {
Write-Info "Added $InstallDir to user PATH (restart your terminal for it to take effect)"
}

Write-Info "Run 'flashduty version' to verify"
Write-Info "Run '$CommandName version' to verify"
} finally {
Remove-Item -Path $TmpDir -Recurse -Force -ErrorAction SilentlyContinue
}
18 changes: 4 additions & 14 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# Environment:
# FLASHDUTY_VERSION Install a specific version (e.g. v0.1.2). Default: latest.
# FLASHDUTY_INSTALL_DIR Install directory. Default: /usr/local/bin.
# INSTALLED_NAME Installed command name. Default: flashduty.
# MIRROR_URL Fetch release assets from this https mirror prefix.
# Default: https://static.flashcat.cloud/flashduty-cli.
# The mirror must replicate
Expand Down Expand Up @@ -134,17 +135,6 @@ detect_shell() {
esac
}

# Emit the completion script for the shell named in $1. Cobra bakes the root
# command name "flashduty" into the script (#compdef / complete -c / function
# names); when installed under a different name, rewrite every occurrence so the
# completion binds to the actual command (the runtime dispatch line already uses
# the typed command word, so it needs no rewrite). The `|` sed delimiter is safe
# because a binary name can't contain it, and the rewrite is a no-op for the
# default "flashduty".
gen_completion() {
"${BIN}" completion "$1" | sed "s|flashduty|${INSTALLED_NAME}|g"
}

# Install completion for the current shell into a directory the shell already
# auto-loads, without ever editing the user's rc files. zsh has no guaranteed
# writable fpath dir, so it only succeeds when a standard site-functions dir is
Expand All @@ -161,7 +151,7 @@ setup_completion() {
dir="${XDG_CONFIG_HOME:-${HOME}/.config}/fish/completions"
mkdir -p "${dir}" 2>/dev/null || true
if [ -w "${dir}" ]; then
gen_completion fish > "${dir}/${INSTALLED_NAME}.fish" && {
"${BIN}" completion fish > "${dir}/${INSTALLED_NAME}.fish" && {
info "Installed fish completion to ${dir}/${INSTALLED_NAME}.fish (restart fish to load)"
return 0
}
Expand All @@ -171,7 +161,7 @@ setup_completion() {
dir="${XDG_DATA_HOME:-${HOME}/.local/share}/bash-completion/completions"
mkdir -p "${dir}" 2>/dev/null || true
if [ -w "${dir}" ]; then
gen_completion bash > "${dir}/${INSTALLED_NAME}" && {
"${BIN}" completion bash > "${dir}/${INSTALLED_NAME}" && {
info "Installed bash completion to ${dir}/${INSTALLED_NAME} (needs the bash-completion package; restart bash to load)"
return 0
}
Expand All @@ -183,7 +173,7 @@ setup_completion() {
"/usr/local/share/zsh/site-functions" \
"/usr/share/zsh/site-functions"; do
if [ -d "${dir}" ] && [ -w "${dir}" ]; then
gen_completion zsh > "${dir}/_${INSTALLED_NAME}" && {
"${BIN}" completion zsh > "${dir}/_${INSTALLED_NAME}" && {
info "Installed zsh completion to ${dir}/_${INSTALLED_NAME}"
info " Run 'rm -f ~/.zcompdump*' and restart zsh to load."
return 0
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func newAuditSearchCmd() *cobra.Command {
cmd.Flags().StringVar(&since, "since", "7d", "Start time")
cmd.Flags().StringVar(&until, "until", "now", "End time")
cmd.Flags().Int64Var(&person, "person", 0, "Filter by person ID")
cmd.Flags().StringVar(&operation, "operation", "", "Filter by exact operation name(s) from 'fduty audit operation-list' (e.g. monitRule:write:update); comma-separate to match several in one call. Prefixes do NOT match (\"monitRule\" returns nothing).")
cmd.Flags().StringVar(&operation, "operation", "", "Filter by exact operation name(s) from 'flashduty audit operation-list' (e.g. monitRule:write:update); comma-separate to match several in one call. Prefixes do NOT match (\"monitRule\" returns nothing).")
cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 99)")
cmd.Flags().IntVar(&page, "page", 1, "Page number")

Expand Down
4 changes: 2 additions & 2 deletions internal/cli/incident.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func newIncidentListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List incidents",
Long: curatedLong("List incidents matching the given filters. The --since/--until window must be < 31 days; --limit max is 100. In json/toon mode, rows default to the compact fields incident_id,num,title,incident_severity,progress,start_time,channel_id,detail_url; pass --fields to choose a different projection.\n\nSee also: fduty insight <team|responder|channel> for aggregated metrics (MTTA, MTTR, noise reduction), fduty insight incident-list for metric-rich filtered incident rows, and fduty insight incident-export for CSV incident exports.", "Incidents", "List"),
Long: curatedLong("List incidents matching the given filters. The --since/--until window must be < 31 days; --limit max is 100. In json/toon mode, rows default to the compact fields incident_id,num,title,incident_severity,progress,start_time,channel_id,detail_url; pass --fields to choose a different projection.\n\nSee also: flashduty insight <team|responder|channel> for aggregated metrics (MTTA, MTTR, noise reduction), flashduty insight incident-list for metric-rich filtered incident rows, and flashduty insight incident-export for CSV incident exports.", "Incidents", "List"),
RunE: func(cmd *cobra.Command, args []string) error {
return runCommand(cmd, args, func(ctx *RunContext) error {
startTime, err := timeutil.Parse(since)
Expand Down Expand Up @@ -1004,7 +1004,7 @@ const maxIncidentVerifyConcurrency = 8
// symbol instead of pinning a copy of the sentence: reword this constant and
// every caller (production and test) picks up the new wording automatically,
// with nothing left to fall out of sync.
const commentVerificationGuidance = "The write API already reported success for the whole batch (a single POST covering every requested incident), and not finding a match here does not mean a comment is missing — this check can only confirm presence, never confirm absence. Do not write the comment again, for the full batch or for the incident(s) listed above alone: either risks a duplicate on a write that most likely already landed. Run `fduty incident timeline <id>` on the listed incident(s) to check by hand before deciding on anything further"
const commentVerificationGuidance = "The write API already reported success for the whole batch (a single POST covering every requested incident), and not finding a match here does not mean a comment is missing — this check can only confirm presence, never confirm absence. Do not write the comment again, for the full batch or for the incident(s) listed above alone: either risks a duplicate on a write that most likely already landed. Run `flashduty incident timeline <id>` on the listed incident(s) to check by hand before deciding on anything further"

// commentVerificationNotFoundDetailFmt is the per-incident detail appended to
// commentVerificationGuidance's problem list when the page walk completes
Expand Down
14 changes: 7 additions & 7 deletions internal/cli/incident_summary_script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ func TestIncidentSummaryScriptCompactOutput(t *testing.T) {
t.Fatalf("resolve repository root: %v", err)
}
script := filepath.Join(root, "skills", "flashduty", "scripts", "incident-summary.sh")
log := filepath.Join(t.TempDir(), "fduty.log")
bin := filepath.Join(t.TempDir(), "fduty")
if err := os.WriteFile(bin, []byte("#!/usr/bin/env bash\nprintf '%s\\n' \"$*\" >> \"$FDUTY_LOG\"\nprintf 'compact result\\n'\n"), 0o755); err != nil {
t.Fatalf("write fake fduty: %v", err)
log := filepath.Join(t.TempDir(), "flashduty.log")
bin := filepath.Join(t.TempDir(), "flashduty")
if err := os.WriteFile(bin, []byte("#!/usr/bin/env bash\nprintf '%s\\n' \"$*\" >> \"$FAKE_FLASHDUTY_LOG\"\nprintf 'compact result\\n'\n"), 0o755); err != nil {
t.Fatalf("write fake flashduty: %v", err)
}
t.Setenv("FDUTY_LOG", log)
t.Setenv("FAKE_FLASHDUTY_LOG", log)
t.Setenv("PATH", filepath.Dir(bin)+string(os.PathListSeparator)+os.Getenv("PATH"))

output, err := exec.Command("bash", script, "inc-1").CombinedOutput()
Expand All @@ -33,11 +33,11 @@ func TestIncidentSummaryScriptCompactOutput(t *testing.T) {
}
invocations, err := os.ReadFile(log)
if err != nil {
t.Fatalf("read fake fduty log: %v", err)
t.Fatalf("read fake flashduty log: %v", err)
}
lines := strings.FieldsFunc(strings.TrimSpace(string(invocations)), func(r rune) bool { return r == '\n' })
if len(lines) != 7 {
t.Fatalf("fduty calls = %d, want 7 (six reads + the start_time probe):\n%s", len(lines), invocations)
t.Fatalf("flashduty calls = %d, want 7 (six reads + the start_time probe):\n%s", len(lines), invocations)
}
wantDetail := "incident detail inc-1 --fields incident_id,num,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id,detail_url --output-format toon"
if lines[0] != wantDetail {
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/incident_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func TestCommandIncidentListHelpSurfacesInsightIncidentExport(t *testing.T) {
if err != nil {
t.Fatalf("incident list --help: %v", err)
}
if !strings.Contains(out, "fduty insight incident-export") {
if !strings.Contains(out, "flashduty insight incident-export") {
t.Fatalf("help output missing incident export discovery hint:\n%s", out)
}
}
2 changes: 1 addition & 1 deletion internal/cli/oncall.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func newOncallWhoCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "who",
Short: "Show who is currently on call",
Long: curatedLong("Show who is currently on call across schedules within a time window, optionally filtered by team or schedule name. The table output already resolves person_ids to display names; when you have raw person_ids elsewhere, batch-resolve them with 'fduty person infos <person_id> ...' (NOT by paginating 'fduty member list' — person_id and member_id are different id namespaces).", "Schedules", "List"),
Long: curatedLong("Show who is currently on call across schedules within a time window, optionally filtered by team or schedule name. The table output already resolves person_ids to display names; when you have raw person_ids elsewhere, batch-resolve them with 'flashduty person infos <person_id> ...' (NOT by paginating 'flashduty member list' — person_id and member_id are different id namespaces).", "Schedules", "List"),
RunE: func(cmd *cobra.Command, args []string) error {
return runCommand(cmd, args, func(ctx *RunContext) error {
startTime, err := timeutil.Parse(since)
Expand Down
54 changes: 46 additions & 8 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"regexp"
"strconv"
"strings"

"github.com/flashcatcloud/go-flashduty"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
toon "github.com/toon-format/toon-go"
"golang.org/x/term"

Expand Down Expand Up @@ -57,7 +60,7 @@ var rootCmd = &cobra.Command{
}
updateNotice = nil
updateCheckWarning = ""
if cmd.CommandPath() == "flashduty update" {
if cmd.CommandPath() == cmd.Root().Name()+" update" {
return nil
}
if update.IsManagedByRunner() {
Expand All @@ -70,7 +73,7 @@ var rootCmd = &cobra.Command{
result, err := checkForUpdateAutoFn(versionStr)
if err != nil {
if update.IsTimeout(err) {
updateCheckWarning = "auto update check timeout, please run 'flashduty update --check' manually"
updateCheckWarning = "auto update check timeout, please run '" + cmd.Root().Name() + " update --check' manually"
} else {
updateNotice = update.StateHasUpdate(versionStr)
}
Expand All @@ -89,9 +92,9 @@ var rootCmd = &cobra.Command{
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\n%s\n", updateCheckWarning)
}
if updateNotice != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\nA new version of flashduty is available: v%s -> %s\n",
update.StripV(updateNotice.CurrentVersion), updateNotice.LatestVersion)
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "To update, run: flashduty update\n")
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\nA new version of %s is available: v%s -> %s\n",
cmd.Root().Name(), update.StripV(updateNotice.CurrentVersion), updateNotice.LatestVersion)
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "To update, run: %s update\n", cmd.Root().Name())
}
},
}
Expand Down Expand Up @@ -162,9 +165,44 @@ func init() {
attachSafariSkillUpload(rootCmd)
}

// Execute runs the root command.
func Execute() error {
return rootCmd.Execute()
// Execute runs the root command as name, the command word the binary was
// invoked as. Help and error text is authored with the canonical "flashduty";
// under any other name the tree's text and the returned error are rewritten
// to it, and cobra derives completion scripts from the root's name.
func Execute(name string) error {
if name == rootCmd.Name() {
return rootCmd.Execute()
}
renameCommandText(rootCmd, name)
rootCmd.Use = name
if err := rootCmd.Execute(); err != nil {
return errors.New(renameCLI(err.Error(), name))
}
return nil
}

// renameCommandText rewrites the CLI name in cmd's help text and flag usages,
// recursively.
func renameCommandText(cmd *cobra.Command, name string) {
cmd.Short = renameCLI(cmd.Short, name)
cmd.Long = renameCLI(cmd.Long, name)
cmd.Example = renameCLI(cmd.Example, name)
rename := func(f *pflag.Flag) { f.Usage = renameCLI(f.Usage, name) }
cmd.Flags().VisitAll(rename)
cmd.PersistentFlags().VisitAll(rename)
for _, c := range cmd.Commands() {
renameCommandText(c, name)
}
}

// cliWord matches "flashduty" used as a command word: followed by a space, and
// not part of a path, domain or longer identifier ("go-flashduty",
// "~/.flashduty").
var cliWord = regexp.MustCompile(`(^|[^\w./~-])flashduty `)

// renameCLI replaces every command-word "flashduty" in s with name.
func renameCLI(s, name string) string {
return cliWord.ReplaceAllString(s, "${1}"+strings.ReplaceAll(name, "$", "$$")+" ")
}

// newClient creates a go-flashduty client using the current factory.
Expand Down
55 changes: 55 additions & 0 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package cli

import (
"testing"

"github.com/spf13/cobra"
)

func TestRenameCLI(t *testing.T) {
cases := []struct{ in, want string }{
{"flashduty incident list", "fduty incident list"},
{"run 'flashduty login' first", "run 'fduty login' first"},
{"Run `flashduty incident timeline <id>`", "Run `fduty incident timeline <id>`"},
{" flashduty team list\n flashduty team get 1", " fduty team list\n fduty team get 1"},
{"cat rows.csv | flashduty enrichment upload", "cat rows.csv | fduty enrichment upload"},
{"flashduty is managed by flashduty-runner", "fduty is managed by flashduty-runner"},
// Not the command word: product name, paths, domains, identifiers.
{"Flashduty CLI", "Flashduty CLI"},
{"go-flashduty client", "go-flashduty client"},
{"~/.flashduty config", "~/.flashduty config"},
{"skills/flashduty cards", "skills/flashduty cards"},
{"flashduty.example.com", "flashduty.example.com"},
{"flashduty", "flashduty"},
}
for _, c := range cases {
if got := renameCLI(c.in, "fduty"); got != c.want {
t.Errorf("renameCLI(%q) = %q, want %q", c.in, got, c.want)
}
}
// "$" in the name is literal text, not a regexp group reference.
if got, want := renameCLI("run flashduty login", "fd$1"), "run fd$1 login"; got != want {
t.Errorf("renameCLI with $ in name = %q, want %q", got, want)
}
}

func TestRenameCommandText(t *testing.T) {
root := &cobra.Command{Use: "flashduty", Long: "Run 'flashduty login'."}
child := &cobra.Command{Use: "list", Short: "List", Example: " flashduty team list"}
child.Flags().String("id", "", "from 'flashduty team list'")
root.PersistentFlags().String("x", "", "see 'flashduty login'")
root.AddCommand(child)

renameCommandText(root, "fduty")

for _, c := range []struct{ got, want string }{
{root.Long, "Run 'fduty login'."},
{child.Example, " fduty team list"},
{child.Flags().Lookup("id").Usage, "from 'fduty team list'"},
{root.PersistentFlags().Lookup("x").Usage, "see 'fduty login'"},
} {
if c.got != c.want {
t.Errorf("got %q, want %q", c.got, c.want)
}
}
}
15 changes: 13 additions & 2 deletions internal/cli/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -54,19 +55,29 @@ func newUpdateCmd() *cobra.Command {
}

func runInstaller(cmd *cobra.Command) error {
// The installer replaces the binary that is running, under its own name
// and directory, not a default-named copy elsewhere.
binPath, err := os.Executable()
if err == nil {
binPath, err = filepath.EvalSymlinks(binPath)
}
if err != nil {
return fmt.Errorf("locate the running binary: %w", err)
}

name, args := installerCommandSpec(runtime.GOOS, update.InstallShellURL(), update.InstallPowerShellURL())
c := exec.Command(name, args...)

c.Stdout = cmd.OutOrStdout()
c.Stderr = cmd.ErrOrStderr()
c.Stdin = os.Stdin
c.Env = update.InstallerEnv(os.Environ())
c.Env = update.InstallerEnv(os.Environ(), binPath)

if err := c.Run(); err != nil {
return fmt.Errorf("update failed: %w", err)
}

_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nUpdate complete. Run 'flashduty version' to verify.\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nUpdate complete. Run '%s version' to verify.\n", cmd.Root().Name())
return nil
}

Expand Down
2 changes: 2 additions & 0 deletions internal/cli/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ func newVersionCmd() *cobra.Command {
_, _ = fmt.Fprintln(out, string(b))
return
}
// Fixed "flashduty version" prefix whatever the invoked name: tests,
// the issue template and external tooling match on it.
_, _ = fmt.Fprintf(out, "flashduty version %s (%s) built %s\n", versionStr, commitStr, dateStr)
},
}
Expand Down
Loading
Loading