diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index 7a8e6de6..6ce5dd91 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -67,6 +67,24 @@ const ( const setupIntent = "Relayfile setup. This signs you in, connects an integration, and prepares a local VFS mount." +// programNameEnv lets a host that mounts this binary say how users reach it. +// +// `agent-relay file ` runs this binary through @relayfile/sdk's Relay CLI +// surface. Someone who arrived that way installed `agent-relay`, not +// `relayfile`, so telling them to "run relayfile login" sends them to a binary +// they do not have. The surface sets this; direct users leave it unset and see +// the name they actually typed. +const programNameEnv = "RELAYFILE_PROGRAM_NAME" + +// programName is how the user invokes this binary, for messages that instruct +// them to run something. Defaults to the binary's own name. +func programName() string { + if name := strings.TrimSpace(os.Getenv(programNameEnv)); name != "" { + return name + } + return "relayfile" +} + var relayfileVersion = relayfileDefaultVersion var relayIntegrationBindingsMu sync.Mutex @@ -116,23 +134,24 @@ type agentRelayMessagingOnlyWorkspaceError struct { func (e *agentRelayMessagingOnlyWorkspaceError) Error() string { name := strings.TrimSpace(e.Name) - setupCommand := "relayfile setup --workspace " + setupCommand := programName() + " setup --workspace " workspaceLabel := "active Agent Relay workspace" if name != "" { - setupCommand = "relayfile setup --workspace " + strconv.Quote(name) + setupCommand = programName() + " setup --workspace " + strconv.Quote(name) workspaceLabel = "Agent Relay workspace " + strconv.Quote(name) } return fmt.Sprintf( - "%s is messaging-only (Relaycast-only) and is not Relayfile-backed. Run `%s` to create a separate Relayfile-backed workspace, or rerun `relayfile login --provision-messaging-only` to provision one automatically. The messaging workspace and its key will remain unchanged.", + "%s is messaging-only (Relaycast-only) and is not Relayfile-backed. Run `%s` to create a separate Relayfile-backed workspace, or rerun `%s login --provision-messaging-only` to provision one automatically. The messaging workspace and its key will remain unchanged.", workspaceLabel, setupCommand, + programName(), ) } type agentRelayInvalidWorkspaceKeyError struct{} func (*agentRelayInvalidWorkspaceKeyError) Error() string { - return "the active Agent Relay workspace key is invalid or unknown: Cloud could not resolve it and Relaycast rejected it. Run `agent-relay workspace list`, switch to a valid workspace with `agent-relay workspace switch `, or create a Relayfile-backed workspace with `relayfile setup --workspace `." + return fmt.Sprintf("the active Agent Relay workspace key is invalid or unknown: Cloud could not resolve it and Relaycast rejected it. Run `agent-relay workspace list`, switch to a valid workspace with `agent-relay workspace switch `, or create a Relayfile-backed workspace with `%s setup --workspace `.", programName()) } type workspaceCatalog struct { @@ -700,11 +719,11 @@ func printHelpForArgs(args []string, stdout io.Writer) { switch command { case "setup": - fmt.Fprintln(stdout, "Usage: relayfile setup [--provider PROVIDER] [--backend BACKEND] [--workspace NAME] [--local-dir DIR]") + fmt.Fprintf(stdout, "Usage: %s setup [--provider PROVIDER] [--backend BACKEND] [--workspace NAME] [--local-dir DIR]\n", programName()) case "login": - fmt.Fprintln(stdout, "Usage: relayfile login [--no-open] [--provision-messaging-only] [--api-key] [--server URL] [--token TOKEN]") + fmt.Fprintf(stdout, "Usage: %s login [--no-open] [--provision-messaging-only] [--api-key] [--server URL] [--token TOKEN]\n", programName()) case "logout": - fmt.Fprintln(stdout, "Usage: relayfile logout") + fmt.Fprintf(stdout, "Usage: %s logout\n", programName()) case "workspace": printWorkspaceUsage(stdout, subcommand) case "integration": @@ -716,45 +735,45 @@ func printHelpForArgs(args []string, stdout io.Writer) { case "digest": printDigestUsage(stdout, subcommand) case "pull": - fmt.Fprintln(stdout, "Usage: relayfile pull [--workspace NAME] [--provider PROVIDER] [--reason TEXT]") + fmt.Fprintf(stdout, "Usage: %s pull [--workspace NAME] [--provider PROVIDER] [--reason TEXT]\n", programName()) case "mount", "start", "on": if subcommand == "checkpoint-seal" { - fmt.Fprintln(stdout, "Usage: relayfile mount checkpoint-seal --root ABS_LOCAL_ROOT --lifecycle-id STABLE_ID --session ID --generation N [--timeout 30s] [--ttl 60s] --json") + fmt.Fprintf(stdout, "Usage: %s mount checkpoint-seal --root ABS_LOCAL_ROOT --lifecycle-id STABLE_ID --session ID --generation N [--timeout 30s] [--ttl 60s] --json\n", programName()) } else if subcommand == "resume-seal" { - fmt.Fprintln(stdout, "Usage: printf '{\"resumeId\":\"...\"}' | relayfile mount resume-seal --root ABS_LOCAL_ROOT [--timeout 60s] --json") + fmt.Fprintf(stdout, "Usage: printf '{\"resumeId\":\"...\"}' | %s mount resume-seal --root ABS_LOCAL_ROOT [--timeout 60s] --json\n", programName()) } else if subcommand == "verify-seal" { - fmt.Fprintln(stdout, "Usage: printf '{\"verificationId\":\"...\",\"receipt\":{...}}' | relayfile mount verify-seal --root ABS_LOCAL_ROOT [--timeout 60s] --json") + fmt.Fprintf(stdout, "Usage: printf '{\"verificationId\":\"...\",\"receipt\":{...}}' | %s mount verify-seal --root ABS_LOCAL_ROOT [--timeout 60s] --json\n", programName()) } else if subcommand == "handback-seal" { - fmt.Fprintln(stdout, "Usage: printf '{\"handbackId\":\"...\",\"consumerIdempotencyKey\":\"...\",\"receipt\":{...}}' | relayfile mount handback-seal --root ABS_LOCAL_ROOT [--timeout 60s] --json") + fmt.Fprintf(stdout, "Usage: printf '{\"handbackId\":\"...\",\"consumerIdempotencyKey\":\"...\",\"receipt\":{...}}' | %s mount handback-seal --root ABS_LOCAL_ROOT [--timeout 60s] --json\n", programName()) } else { printMountHelp(stdout) } case "restart": - fmt.Fprintln(stdout, "Usage: relayfile restart [WORKSPACE] [--foreground]") + fmt.Fprintf(stdout, "Usage: %s restart [WORKSPACE] [--foreground]\n", programName()) case "tree", "ls": - fmt.Fprintln(stdout, "Usage: relayfile tree [WORKSPACE] [PATH] [--depth N] [--json]") + fmt.Fprintf(stdout, "Usage: %s tree [WORKSPACE] [PATH] [--depth N] [--json]\n", programName()) case "read", "cat": - fmt.Fprintln(stdout, "Usage: relayfile read [WORKSPACE] PATH [--output FILE] [--json]") + fmt.Fprintf(stdout, "Usage: %s read [WORKSPACE] PATH [--output FILE] [--json]\n", programName()) case "seed": - fmt.Fprintln(stdout, "Usage: relayfile seed [WORKSPACE] [DIR]") + fmt.Fprintf(stdout, "Usage: %s seed [WORKSPACE] [DIR]\n", programName()) case "export": - fmt.Fprintln(stdout, "Usage: relayfile export [WORKSPACE] --format FORMAT [--output FILE]") + fmt.Fprintf(stdout, "Usage: %s export [WORKSPACE] --format FORMAT [--output FILE]\n", programName()) case "status": - fmt.Fprintln(stdout, "Usage: relayfile status [WORKSPACE] [--json]") + fmt.Fprintf(stdout, "Usage: %s status [WORKSPACE] [--json]\n", programName()) case "stop", "off": - fmt.Fprintln(stdout, "Usage: relayfile stop [WORKSPACE]") + fmt.Fprintf(stdout, "Usage: %s stop [WORKSPACE]\n", programName()) case "supervisor": - fmt.Fprintln(stdout, "Usage: relayfile supervisor [WORKSPACE] [LISTEN_FILTERS...]") + fmt.Fprintf(stdout, "Usage: %s supervisor [WORKSPACE] [LISTEN_FILTERS...]\n", programName()) case "logs": - fmt.Fprintln(stdout, "Usage: relayfile logs [WORKSPACE] [--lines N]") + fmt.Fprintf(stdout, "Usage: %s logs [WORKSPACE] [--lines N]\n", programName()) case "observer": - fmt.Fprintln(stdout, "Usage: relayfile observer [WORKSPACE] [--no-open]") + fmt.Fprintf(stdout, "Usage: %s observer [WORKSPACE] [--no-open]\n", programName()) case "listen", "watch": - fmt.Fprintln(stdout, "Usage: relayfile listen [WORKSPACE] [--provider PROVIDER] [--path GLOB] [--event TYPE] [--run CMD] [--format text|json] [--background]") + fmt.Fprintf(stdout, "Usage: %s listen [WORKSPACE] [--provider PROVIDER] [--path GLOB] [--event TYPE] [--run CMD] [--format text|json] [--background]\n", programName()) case "control-plane": - fmt.Fprintln(stdout, "Usage: relayfile control-plane serve [--sock PATH]") + fmt.Fprintf(stdout, "Usage: %s control-plane serve [--sock PATH]\n", programName()) case "dev": - fmt.Fprintln(stdout, "Usage: relayfile dev [WORKSPACE] [--provider PROVIDER] [--path GLOB] [--event TYPE] [--run CMD]") + fmt.Fprintf(stdout, "Usage: %s dev [WORKSPACE] [--provider PROVIDER] [--path GLOB] [--event TYPE] [--run CMD]\n", programName()) case "help": printUsage(stdout) default: @@ -765,82 +784,85 @@ func printHelpForArgs(args []string, stdout io.Writer) { func printWorkspaceUsage(w io.Writer, subcommand string) { switch subcommand { case "create": - fmt.Fprintln(w, "Usage: relayfile workspace create NAME") + fmt.Fprintf(w, "Usage: %s workspace create NAME\n", programName()) case "join": - fmt.Fprintln(w, "Usage: relayfile workspace join WORKSPACE_ID [--name NAME] [--write]") + fmt.Fprintf(w, "Usage: %s workspace join WORKSPACE_ID [--name NAME] [--write]\n", programName()) case "use": - fmt.Fprintln(w, "Usage: relayfile workspace use NAME") + fmt.Fprintf(w, "Usage: %s workspace use NAME\n", programName()) case "list": - fmt.Fprintln(w, "Usage: relayfile workspace list [--names-only]") + fmt.Fprintf(w, "Usage: %s workspace list [--names-only]\n", programName()) case "current": - fmt.Fprintln(w, "Usage: relayfile workspace current [--verbose]") + fmt.Fprintf(w, "Usage: %s workspace current [--verbose]\n", programName()) case "view": - fmt.Fprintln(w, "Usage: relayfile workspace view ...") + fmt.Fprintf(w, "Usage: %s workspace view ...\n", programName()) case "status": - fmt.Fprintln(w, "Usage: relayfile workspace status [--workspace NAME] [--json]") + fmt.Fprintf(w, "Usage: %s workspace status [--workspace NAME] [--json]\n", programName()) case "delete": - fmt.Fprintln(w, "Usage: relayfile workspace delete NAME [--yes]") + fmt.Fprintf(w, "Usage: %s workspace delete NAME [--yes]\n", programName()) default: - fmt.Fprintln(w, `Usage: - relayfile workspace create NAME - relayfile workspace join WORKSPACE_ID [--name NAME] [--write] - relayfile workspace use NAME - relayfile workspace list [--names-only] - relayfile workspace current [--verbose] - relayfile workspace view ... - relayfile workspace status [--workspace NAME] [--json] - relayfile workspace delete NAME [--yes]`) + fmt.Fprintf(w, `Usage: + %[1]s workspace create NAME + %[1]s workspace join WORKSPACE_ID [--name NAME] [--write] + %[1]s workspace use NAME + %[1]s workspace list [--names-only] + %[1]s workspace current [--verbose] + %[1]s workspace view ... + %[1]s workspace status [--workspace NAME] [--json] + %[1]s workspace delete NAME [--yes] +`, programName()) } } func printIntegrationUsage(w io.Writer, subcommand string) { switch subcommand { case "connect": - fmt.Fprintln(w, "Usage: relayfile integration connect PROVIDER [--backend BACKEND] [--workspace NAME] [--no-open] [--timeout 5m] [--wait-sync]") + fmt.Fprintf(w, "Usage: %s integration connect PROVIDER [--backend BACKEND] [--workspace NAME] [--no-open] [--timeout 5m] [--wait-sync]\n", programName()) case "available", "catalog", "providers": - fmt.Fprintln(w, "Usage: relayfile integration available [--search QUERY] [--backend BACKEND] [--json] [--refresh]") + fmt.Fprintf(w, "Usage: %s integration available [--search QUERY] [--backend BACKEND] [--json] [--refresh]\n", programName()) case "search": - fmt.Fprintln(w, "Usage: relayfile integration search QUERY [--backend BACKEND] [--json] [--refresh]") + fmt.Fprintf(w, "Usage: %s integration search QUERY [--backend BACKEND] [--json] [--refresh]\n", programName()) case "list": - fmt.Fprintln(w, "Usage: relayfile integration list [--workspace NAME] [--json]") + fmt.Fprintf(w, "Usage: %s integration list [--workspace NAME] [--json]\n", programName()) case "disconnect": - fmt.Fprintln(w, "Usage: relayfile integration disconnect PROVIDER [--workspace NAME] [--yes]") + fmt.Fprintf(w, "Usage: %s integration disconnect PROVIDER [--workspace NAME] [--yes]\n", programName()) case "adopt": - fmt.Fprintln(w, "Usage: relayfile integration adopt PROVIDER --connection-id ID [--workspace NAME] [--provider-config-key KEY] [--yes]") + fmt.Fprintf(w, "Usage: %s integration adopt PROVIDER --connection-id ID [--workspace NAME] [--provider-config-key KEY] [--yes]\n", programName()) case "set-metadata": - fmt.Fprintln(w, "Usage: relayfile integration set-metadata PROVIDER KEY=VALUE [KEY=VALUE...] [--workspace NAME] [--yes]") + fmt.Fprintf(w, "Usage: %s integration set-metadata PROVIDER KEY=VALUE [KEY=VALUE...] [--workspace NAME] [--yes]\n", programName()) case "bind": - fmt.Fprintln(w, "Usage: relayfile integration bind PROVIDER RESOURCE_OR_PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN") + fmt.Fprintf(w, "Usage: %s integration bind PROVIDER RESOURCE_OR_PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN\n", programName()) case "resolve-path": - fmt.Fprintln(w, "Usage: relayfile integration resolve-path PROVIDER RESOURCE [--json]") + fmt.Fprintf(w, "Usage: %s integration resolve-path PROVIDER RESOURCE [--json]\n", programName()) case "unbind": - fmt.Fprintln(w, "Usage: relayfile integration unbind PROVIDER [RESOURCE_OR_PATH_GLOB|--resource RESOURCE_OR_PATH_GLOB]") + fmt.Fprintf(w, "Usage: %s integration unbind PROVIDER [RESOURCE_OR_PATH_GLOB|--resource RESOURCE_OR_PATH_GLOB]\n", programName()) default: - fmt.Fprintln(w, `Usage: - relayfile integration connect PROVIDER [--backend BACKEND] [--workspace NAME] [--wait-sync] - relayfile integration available [--search QUERY] [--backend BACKEND] [--json] [--refresh] - relayfile integration search QUERY [--backend BACKEND] [--json] [--refresh] - relayfile integration list [--workspace NAME] [--json] - relayfile integration disconnect PROVIDER [--workspace NAME] [--yes] - relayfile integration adopt PROVIDER --connection-id ID [--workspace NAME] [--provider-config-key KEY] [--yes] - relayfile integration set-metadata PROVIDER KEY=VALUE [KEY=VALUE...] [--workspace NAME] [--yes] - relayfile integration bind PROVIDER RESOURCE_OR_PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN - relayfile integration resolve-path PROVIDER RESOURCE [--json] - relayfile integration unbind PROVIDER [RESOURCE_OR_PATH_GLOB|--resource RESOURCE_OR_PATH_GLOB] - relayfile integration writeback-secret --channel CHANNEL [--workspace WS] [--json]`) + fmt.Fprintf(w, `Usage: + %[1]s integration connect PROVIDER [--backend BACKEND] [--workspace NAME] [--wait-sync] + %[1]s integration available [--search QUERY] [--backend BACKEND] [--json] [--refresh] + %[1]s integration search QUERY [--backend BACKEND] [--json] [--refresh] + %[1]s integration list [--workspace NAME] [--json] + %[1]s integration disconnect PROVIDER [--workspace NAME] [--yes] + %[1]s integration adopt PROVIDER --connection-id ID [--workspace NAME] [--provider-config-key KEY] [--yes] + %[1]s integration set-metadata PROVIDER KEY=VALUE [KEY=VALUE...] [--workspace NAME] [--yes] + %[1]s integration bind PROVIDER RESOURCE_OR_PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN + %[1]s integration resolve-path PROVIDER RESOURCE [--json] + %[1]s integration unbind PROVIDER [RESOURCE_OR_PATH_GLOB|--resource RESOURCE_OR_PATH_GLOB] + %[1]s integration writeback-secret --channel CHANNEL [--workspace WS] [--json] +`, programName()) } } func printOpsUsage(w io.Writer, subcommand string) { switch subcommand { case "list": - fmt.Fprintln(w, "Usage: relayfile ops list [--workspace NAME] [--json] [--no-refresh]") + fmt.Fprintf(w, "Usage: %s ops list [--workspace NAME] [--json] [--no-refresh]\n", programName()) case "replay": - fmt.Fprintln(w, "Usage: relayfile ops replay OPID [--workspace NAME]") + fmt.Fprintf(w, "Usage: %s ops replay OPID [--workspace NAME]\n", programName()) default: - fmt.Fprintln(w, `Usage: - relayfile ops list [--workspace NAME] [--json] - relayfile ops replay OPID [--workspace NAME]`) + fmt.Fprintf(w, `Usage: + %[1]s ops list [--workspace NAME] [--json] + %[1]s ops replay OPID [--workspace NAME] +`, programName()) } } @@ -849,29 +871,30 @@ func printWritebackUsage(w io.Writer, subcommand string) { case "list": fmt.Fprintln(w, writebackListUsage) case "status": - fmt.Fprintln(w, "Usage: relayfile writeback status [WORKSPACE] [--json]") + fmt.Fprintf(w, "Usage: %s writeback status [WORKSPACE] [--json]\n", programName()) case "push": - fmt.Fprintln(w, "Usage: relayfile writeback push LOCAL_PATH [--workspace WS] [--json] [--timeout 90s]") + fmt.Fprintf(w, "Usage: %s writeback push LOCAL_PATH [--workspace WS] [--json] [--timeout 90s]\n", programName()) case "update": - fmt.Fprintln(w, "Usage: relayfile writeback update LOCAL_PATH [--workspace WS] [--json] [--timeout 90s]") + fmt.Fprintf(w, "Usage: %s writeback update LOCAL_PATH [--workspace WS] [--json] [--timeout 90s]\n", programName()) case "delete": - fmt.Fprintln(w, "Usage: relayfile writeback delete LOCAL_PATH [--workspace WS] [--json] [--timeout 90s]") + fmt.Fprintf(w, "Usage: %s writeback delete LOCAL_PATH [--workspace WS] [--json] [--timeout 90s]\n", programName()) case "retry": - fmt.Fprintln(w, "Usage: relayfile writeback retry --op-id OP [WORKSPACE]") + fmt.Fprintf(w, "Usage: %s writeback retry --op-id OP [WORKSPACE]\n", programName()) case "skip-stuck": - fmt.Fprintln(w, "Usage: relayfile writeback skip-stuck [WORKSPACE] [--workspace WS] [--max N] [--json]") + fmt.Fprintf(w, "Usage: %s writeback skip-stuck [WORKSPACE] [--workspace WS] [--max N] [--json]\n", programName()) case "sweep-drafts": fmt.Fprintln(w, writebackSweepUsage) default: - fmt.Fprintln(w, `Usage: - relayfile writeback list --state pending|dead [--workspace WS] [--json] - relayfile writeback status [WORKSPACE] [--json] - relayfile writeback retry --op-id OP [WORKSPACE] - relayfile writeback push LOCAL_PATH [--workspace WS] [--json] [--timeout 90s] - relayfile writeback update LOCAL_PATH [--workspace WS] [--json] [--timeout 90s] - relayfile writeback delete LOCAL_PATH [--workspace WS] [--json] [--timeout 90s] - relayfile writeback skip-stuck [WORKSPACE] [--workspace WS] [--max N] [--json] - relayfile writeback sweep-drafts [WORKSPACE] [--path-prefix PREFIX] [--pattern GLOB ...] [--apply] [--json]`) + fmt.Fprintf(w, `Usage: + %[1]s writeback list --state pending|dead [--workspace WS] [--json] + %[1]s writeback status [WORKSPACE] [--json] + %[1]s writeback retry --op-id OP [WORKSPACE] + %[1]s writeback push LOCAL_PATH [--workspace WS] [--json] [--timeout 90s] + %[1]s writeback update LOCAL_PATH [--workspace WS] [--json] [--timeout 90s] + %[1]s writeback delete LOCAL_PATH [--workspace WS] [--json] [--timeout 90s] + %[1]s writeback skip-stuck [WORKSPACE] [--workspace WS] [--max N] [--json] + %[1]s writeback sweep-drafts [WORKSPACE] [--path-prefix PREFIX] [--pattern GLOB ...] [--apply] [--json] +`, programName()) } } @@ -880,65 +903,66 @@ func printDigestUsage(w io.Writer, subcommand string) { case "rebuild": fmt.Fprintln(w, digestRebuildUsage) default: - fmt.Fprintln(w, `Usage: - relayfile digest rebuild --window today|yesterday|YYYY-MM-DD|this-week|last-week [--workspace NAME] [--json]`) + fmt.Fprintf(w, `Usage: + %[1]s digest rebuild --window today|yesterday|YYYY-MM-DD|this-week|last-week [--workspace NAME] [--json] +`, programName()) } } func printUsage(w io.Writer) { - fmt.Fprintln(w, `relayfile is the RelayFile CLI. + fmt.Fprintf(w, `%[1]s is the RelayFile CLI. Usage: - relayfile (hosted GitHub quickstart for the current project) - relayfile setup [--provider PROVIDER] [--backend BACKEND] [--workspace NAME] [--local-dir DIR] - relayfile login [--no-open] [--provision-messaging-only] [--api-key] [--server URL] [--token TOKEN] - relayfile logout - relayfile workspace create NAME - relayfile workspace join WORKSPACE_ID [--name NAME] [--write] - relayfile workspace use NAME - relayfile workspace list [--names-only] - relayfile workspace current [--verbose] - relayfile workspace status [--workspace NAME] [--json] - relayfile workspace delete NAME [--yes] - relayfile integration connect PROVIDER [--backend BACKEND] [--workspace NAME] + %[1]s (hosted GitHub quickstart for the current project) + %[1]s setup [--provider PROVIDER] [--backend BACKEND] [--workspace NAME] [--local-dir DIR] + %[1]s login [--no-open] [--provision-messaging-only] [--api-key] [--server URL] [--token TOKEN] + %[1]s logout + %[1]s workspace create NAME + %[1]s workspace join WORKSPACE_ID [--name NAME] [--write] + %[1]s workspace use NAME + %[1]s workspace list [--names-only] + %[1]s workspace current [--verbose] + %[1]s workspace status [--workspace NAME] [--json] + %[1]s workspace delete NAME [--yes] + %[1]s integration connect PROVIDER [--backend BACKEND] [--workspace NAME] (for jira/confluence: prompts for the Atlassian site to bind after OAuth completes) - relayfile integration available [--search QUERY] [--backend BACKEND] [--json] [--refresh] - relayfile integration search QUERY [--backend BACKEND] [--json] [--refresh] - relayfile integration list [--workspace NAME] [--json] - relayfile integration disconnect PROVIDER [--workspace NAME] [--yes] - relayfile integration adopt PROVIDER --connection-id ID [--workspace NAME] [--provider-config-key KEY] [--yes] - relayfile integration set-metadata PROVIDER KEY=VALUE [KEY=VALUE...] [--workspace NAME] [--yes] - relayfile integration bind PROVIDER RESOURCE_OR_PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN - relayfile integration resolve-path PROVIDER RESOURCE [--json] - relayfile integration unbind PROVIDER [RESOURCE_OR_PATH_GLOB|--resource RESOURCE_OR_PATH_GLOB] - relayfile ops list [--workspace NAME] [--json] - relayfile ops replay OPID [--workspace NAME] - relayfile writeback list --state pending|dead [--workspace WS] [--json] - relayfile writeback status [WORKSPACE] [--json] - relayfile writeback retry --op-id OP [WORKSPACE] - relayfile writeback push LOCAL_PATH [--workspace WS] [--json] [--timeout 90s] - relayfile writeback update LOCAL_PATH [--workspace WS] [--json] [--timeout 90s] - relayfile writeback delete LOCAL_PATH [--workspace WS] [--json] [--timeout 90s] - relayfile writeback skip-stuck [WORKSPACE] [--workspace WS] [--max N] [--json] - relayfile digest rebuild --window today|yesterday|YYYY-MM-DD|this-week|last-week [--workspace NAME] [--json] - relayfile pull [--workspace NAME] [--provider PROVIDER] [--reason TEXT] - relayfile mount [WORKSPACE] [LOCAL_DIR] - relayfile start [WORKSPACE] [LOCAL_DIR] (alias for mount; pass --background to detach) - relayfile on [WORKSPACE] [LOCAL_DIR] (alias for mount; pass --background to detach) - relayfile stop [WORKSPACE] - relayfile off [WORKSPACE] (alias for stop) - relayfile restart [WORKSPACE] [--foreground] - relayfile supervisor install [WORKSPACE] [LISTEN_FILTERS...] - relayfile supervisor uninstall [WORKSPACE] - relayfile supervisor status [WORKSPACE] - relayfile tree [WORKSPACE] [PATH] [--depth N] - relayfile read [WORKSPACE] PATH - relayfile seed [WORKSPACE] [DIR] - relayfile export [WORKSPACE] --format FORMAT [--output FILE] - relayfile status [WORKSPACE] - relayfile logs [WORKSPACE] - relayfile observer [WORKSPACE] [--no-open] - relayfile control-plane serve [--sock PATH] + %[1]s integration available [--search QUERY] [--backend BACKEND] [--json] [--refresh] + %[1]s integration search QUERY [--backend BACKEND] [--json] [--refresh] + %[1]s integration list [--workspace NAME] [--json] + %[1]s integration disconnect PROVIDER [--workspace NAME] [--yes] + %[1]s integration adopt PROVIDER --connection-id ID [--workspace NAME] [--provider-config-key KEY] [--yes] + %[1]s integration set-metadata PROVIDER KEY=VALUE [KEY=VALUE...] [--workspace NAME] [--yes] + %[1]s integration bind PROVIDER RESOURCE_OR_PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN + %[1]s integration resolve-path PROVIDER RESOURCE [--json] + %[1]s integration unbind PROVIDER [RESOURCE_OR_PATH_GLOB|--resource RESOURCE_OR_PATH_GLOB] + %[1]s ops list [--workspace NAME] [--json] + %[1]s ops replay OPID [--workspace NAME] + %[1]s writeback list --state pending|dead [--workspace WS] [--json] + %[1]s writeback status [WORKSPACE] [--json] + %[1]s writeback retry --op-id OP [WORKSPACE] + %[1]s writeback push LOCAL_PATH [--workspace WS] [--json] [--timeout 90s] + %[1]s writeback update LOCAL_PATH [--workspace WS] [--json] [--timeout 90s] + %[1]s writeback delete LOCAL_PATH [--workspace WS] [--json] [--timeout 90s] + %[1]s writeback skip-stuck [WORKSPACE] [--workspace WS] [--max N] [--json] + %[1]s digest rebuild --window today|yesterday|YYYY-MM-DD|this-week|last-week [--workspace NAME] [--json] + %[1]s pull [--workspace NAME] [--provider PROVIDER] [--reason TEXT] + %[1]s mount [WORKSPACE] [LOCAL_DIR] + %[1]s start [WORKSPACE] [LOCAL_DIR] (alias for mount; pass --background to detach) + %[1]s on [WORKSPACE] [LOCAL_DIR] (alias for mount; pass --background to detach) + %[1]s stop [WORKSPACE] + %[1]s off [WORKSPACE] (alias for stop) + %[1]s restart [WORKSPACE] [--foreground] + %[1]s supervisor install [WORKSPACE] [LISTEN_FILTERS...] + %[1]s supervisor uninstall [WORKSPACE] + %[1]s supervisor status [WORKSPACE] + %[1]s tree [WORKSPACE] [PATH] [--depth N] + %[1]s read [WORKSPACE] PATH + %[1]s seed [WORKSPACE] [DIR] + %[1]s export [WORKSPACE] --format FORMAT [--output FILE] + %[1]s status [WORKSPACE] + %[1]s logs [WORKSPACE] + %[1]s observer [WORKSPACE] [--no-open] + %[1]s control-plane serve [--sock PATH] Subcommands: setup Sign in, connect an integration, and mount the workspace @@ -973,7 +997,8 @@ Subcommands: export Export a workspace as json, tar, or patch status Show sync status and local mirror state for a workspace logs Print the background mount log - observer Open the hosted file observer for a workspace`) + observer Open the hosted file observer for a workspace +`, programName()) } type setupRunOptions struct { @@ -1014,7 +1039,7 @@ func runSetupWithOptions(args []string, stdin io.Reader, stdout io.Writer, optio return err } if fs.NArg() > 0 { - return errors.New("usage: relayfile setup [--provider PROVIDER] [--backend BACKEND] [--workspace NAME] [--local-dir DIR]") + return fmt.Errorf("usage: %s setup [--provider PROVIDER] [--backend BACKEND] [--workspace NAME] [--local-dir DIR]", programName()) } cloudAPI := strings.TrimRight(strings.TrimSpace(*cloudAPIURL), "/") @@ -1159,7 +1184,7 @@ func runSetupWithOptions(args []string, stdin io.Reader, stdout io.Writer, optio mountArgs = append(mountArgs, "--once") } if *skipMount { - fmt.Fprintf(stdout, "Setup complete. Start the VFS mount with:\n relayfile mount %s %s\n", record.ID, localDir) + fmt.Fprintf(stdout, "Setup complete. Start the VFS mount with:\n %s mount %s %s\n", programName(), record.ID, localDir) return nil } @@ -1518,8 +1543,9 @@ func classifyAgentRelayActiveWorkspaceError(err error) error { return &agentRelayInvalidWorkspaceKeyError{} default: return fmt.Errorf( - "the active Agent Relay workspace could not be resolved through Cloud, and Relaycast verification returned HTTP %d. Try again or run `relayfile setup --workspace ` to create a Relayfile-backed workspace", + "the active Agent Relay workspace could not be resolved through Cloud, and Relaycast verification returned HTTP %d. Try again or run `%s setup --workspace ` to create a Relayfile-backed workspace", statusCode, + programName(), ) } } @@ -2598,7 +2624,7 @@ func bootstrapDelegatedCredentialsFromAgentRelayWithOptions(workspaceValue strin func provisionRelayfileWorkspaceForMessagingOnly(cloud cloudCredentials, name string, scopes []string) (workspaceRecord, string, error) { name = strings.TrimSpace(name) if name == "" { - return workspaceRecord{}, "", errors.New("the messaging-only workspace did not include a name; run `relayfile setup --workspace ` to create a separate Relayfile-backed workspace") + return workspaceRecord{}, "", fmt.Errorf("the messaging-only workspace did not include a name; run `%s setup --workspace ` to create a separate Relayfile-backed workspace", programName()) } // Keep the Relayfile-backed workspace visually distinct from the original // Relaycast-only workspace. ensureWorkspaceForSetup deduplicates this name @@ -2964,7 +2990,7 @@ func runLogin(args []string, stdin io.Reader, stdout io.Writer) error { if (strings.TrimSpace(*cloudAPIURL) != "" && strings.TrimRight(strings.TrimSpace(*cloudAPIURL), "/") != defaultCloudAPIURL) || strings.TrimSpace(*cloudToken) != "" || *loginTimeout != 5*time.Minute || *skipWorkspace || strings.TrimSpace(*workspaceFlag) != "" { - fmt.Fprintln(stdout, "warning: relayfile login delegates cloud sign-in to agent-relay; relayfile cloud flags are deprecated") + fmt.Fprintf(stdout, "warning: %s login delegates cloud sign-in to agent-relay; its cloud flags are deprecated\n", programName()) } if err := runAgentRelayLogin(stdin, stdout, *noOpen); err != nil { return err @@ -2994,7 +3020,7 @@ func runLogin(args []string, stdin io.Reader, stdout io.Writer) error { func runLogout(args []string, stdout io.Writer) error { if len(args) > 0 { - return errors.New("usage: relayfile logout") + return fmt.Errorf("usage: %s logout", programName()) } cloudRemoved, err := revokeAndClearAgentRelayCloudSession(context.Background()) if err != nil { @@ -3230,7 +3256,7 @@ func runIntegrationBind(args []string, stdout io.Writer) error { } if *list { if fs.NArg() != 0 { - return errors.New("usage: relayfile integration bind --list") + return fmt.Errorf("usage: %s integration bind --list", programName()) } bindings, err := listRelayIntegrationBindings() if err != nil { @@ -3239,7 +3265,7 @@ func runIntegrationBind(args []string, stdout io.Writer) error { return writeJSON(stdout, bindings) } if fs.NArg() != 2 { - return errors.New("usage: relayfile integration bind PROVIDER RESOURCE_OR_PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN [--subscription ID] [--webhook-subscription ID --webhook-subscription-workspace WS]") + return fmt.Errorf("usage: %s integration bind PROVIDER RESOURCE_OR_PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN [--subscription ID] [--webhook-subscription ID --webhook-subscription-workspace WS]", programName()) } binding, replaced, warning, err := bindRelayIntegration(relayIntegrationBindInput{ Provider: fs.Arg(0), @@ -3275,7 +3301,7 @@ func runIntegrationResolvePath(args []string, stdout io.Writer) error { return err } if fs.NArg() != 2 { - return errors.New("usage: relayfile integration resolve-path PROVIDER RESOURCE [--json]") + return fmt.Errorf("usage: %s integration resolve-path PROVIDER RESOURCE [--json]", programName()) } provider := normalizeProviderID(fs.Arg(0)) if err := validateLocalProviderID(provider); err != nil { @@ -3375,7 +3401,7 @@ func runIntegrationUnbind(args []string, stdout io.Writer) error { return err } if fs.NArg() < 1 || fs.NArg() > 2 { - return errors.New("usage: relayfile integration unbind PROVIDER [RESOURCE_OR_PATH_GLOB|--resource RESOURCE_OR_PATH_GLOB]") + return fmt.Errorf("usage: %s integration unbind PROVIDER [RESOURCE_OR_PATH_GLOB|--resource RESOURCE_OR_PATH_GLOB]", programName()) } pathGlob := strings.TrimSpace(*resource) if fs.NArg() == 2 { @@ -3428,7 +3454,7 @@ func runIntegrationWritebackSecret(args []string, stdout io.Writer) error { return err } if fs.NArg() != 0 { - return errors.New("usage: relayfile integration writeback-secret --channel CHANNEL [--workspace WS] [--json]") + return fmt.Errorf("usage: %s integration writeback-secret --channel CHANNEL [--workspace WS] [--json]", programName()) } channelValue := strings.TrimSpace(*channel) if channelValue == "" { @@ -3494,7 +3520,7 @@ func runIntegrationConnect(args []string, stdin io.Reader, stdout io.Writer) err return err } if fs.NArg() != 1 { - return errors.New("usage: relayfile integration connect PROVIDER [--backend BACKEND] [--workspace NAME] [--no-open] [--timeout 5m] [--wait-sync]") + return fmt.Errorf("usage: %s integration connect PROVIDER [--backend BACKEND] [--workspace NAME] [--no-open] [--timeout 5m] [--wait-sync]", programName()) } provider := normalizeProviderID(fs.Arg(0)) requestedBackend, err := normalizeIntegrationBackend(*backend) @@ -3544,9 +3570,9 @@ func runIntegrationConnect(args []string, stdin io.Reader, stdout io.Writer) err return waitForInitialSync(delegated.ServerURL(), delegated.BearerToken(), relayWorkspaceID, provider, record.LocalDir, *timeout, stdout) } if record.LocalDir != "" { - fmt.Fprintf(stdout, "Run `relayfile mount %s %s` to mirror files locally, or rerun with --wait-sync to block until initial data is ready.\n", record.ID, record.LocalDir) + fmt.Fprintf(stdout, "Run `%s mount %s %s` to mirror files locally, or rerun with --wait-sync to block until initial data is ready.\n", programName(), record.ID, record.LocalDir) } else { - fmt.Fprintln(stdout, "Run `relayfile mount` to mirror files locally, or rerun with --wait-sync to block until initial data is ready.") + fmt.Fprintf(stdout, "Run `%s mount` to mirror files locally, or rerun with --wait-sync to block until initial data is ready.\n", programName()) } return nil } @@ -3716,7 +3742,7 @@ func runIntegrationSearch(args []string, stdout io.Writer) error { return err } if fs.NArg() != 1 { - return errors.New("usage: relayfile integration search QUERY [--backend BACKEND] [--json] [--refresh]") + return fmt.Errorf("usage: %s integration search QUERY [--backend BACKEND] [--json] [--refresh]", programName()) } availableArgs := []string{ "--cloud-api-url", *cloudAPIURL, @@ -3752,7 +3778,7 @@ func runIntegrationAvailable(args []string, stdout io.Writer) error { return err } if fs.NArg() > 0 { - return errors.New("usage: relayfile integration available [--search QUERY] [--backend BACKEND] [--json] [--refresh]") + return fmt.Errorf("usage: %s integration available [--search QUERY] [--backend BACKEND] [--json] [--refresh]", programName()) } requestedBackend, err := normalizeIntegrationBackend(*backend) if err != nil { @@ -3874,7 +3900,7 @@ func runIntegrationList(args []string, stdout io.Writer) error { return err } if fs.NArg() > 0 { - return errors.New("usage: relayfile integration list [--workspace NAME] [--json] [--cloud-token TOKEN]") + return fmt.Errorf("usage: %s integration list [--workspace NAME] [--json] [--cloud-token TOKEN]", programName()) } cloudTokenPassedExplicitly := false fs.Visit(func(item *flag.Flag) { @@ -4028,7 +4054,7 @@ func runIntegrationDisconnect(args []string, stdin io.Reader, stdout io.Writer) return err } if fs.NArg() != 1 { - return errors.New("usage: relayfile integration disconnect PROVIDER [--workspace NAME] [--yes]") + return fmt.Errorf("usage: %s integration disconnect PROVIDER [--workspace NAME] [--yes]", programName()) } provider := normalizeProviderID(fs.Arg(0)) record, err := resolveWorkspaceRecord(strings.TrimSpace(*workspaceName)) @@ -4093,7 +4119,7 @@ func runIntegrationAdopt(args []string, stdin io.Reader, stdout io.Writer) error return err } if fs.NArg() != 1 { - return errors.New("usage: relayfile integration adopt PROVIDER --connection-id ID [--workspace NAME] [--provider-config-key KEY] [--yes]") + return fmt.Errorf("usage: %s integration adopt PROVIDER --connection-id ID [--workspace NAME] [--provider-config-key KEY] [--yes]", programName()) } provider := normalizeProviderID(fs.Arg(0)) if err := validateLocalProviderID(provider); err != nil { @@ -4214,9 +4240,13 @@ func runIntegrationSetMetadata(args []string, stdin io.Reader, stdout io.Writer) } rest := fs.Args() if len(rest) < 2 { - return errors.New("usage: relayfile integration set-metadata PROVIDER KEY=VALUE [KEY=VALUE...] [--workspace NAME] [--yes]\n\n" + - " v1 accepts flat KEY=VALUE pairs only; nested keys are not yet supported.\n" + - " Example: relayfile integration set-metadata jira cloudId=abc-123 baseUrl=https://foo.atlassian.net") + return fmt.Errorf( + "usage: %s integration set-metadata PROVIDER KEY=VALUE [KEY=VALUE...] [--workspace NAME] [--yes]\n\n"+ + " v1 accepts flat KEY=VALUE pairs only; nested keys are not yet supported.\n"+ + " Example: %s integration set-metadata jira cloudId=abc-123 baseUrl=https://foo.atlassian.net", + programName(), + programName(), + ) } provider := normalizeProviderID(rest[0]) if err := validateLocalProviderID(provider); err != nil { @@ -4852,7 +4882,7 @@ func runWritebackFileMutation(mode writebackCommandMode, args []string, stdout i return err } if fs.NArg() != 1 { - return fmt.Errorf("usage: relayfile writeback %s LOCAL_PATH [--workspace WS] [--json] [--timeout 90s]", mode) + return fmt.Errorf("usage: %s writeback %s LOCAL_PATH [--workspace WS] [--json] [--timeout 90s]", programName(), mode) } resolved, err := resolveWritebackPushPath(fs.Arg(0), strings.TrimSpace(*workspaceName)) @@ -5495,7 +5525,7 @@ func runWritebackSkipStuck(args []string, stdout io.Writer) error { return err } if fs.NArg() > 1 { - return errors.New("usage: relayfile writeback skip-stuck [WORKSPACE] [--workspace WS] [--max N] [--json]") + return fmt.Errorf("usage: %s writeback skip-stuck [WORKSPACE] [--workspace WS] [--max N] [--json]", programName()) } if *maxSkips < 0 { return errors.New("--max must be >= 0") @@ -5560,7 +5590,7 @@ func runWritebackSkipStuck(args []string, stdout io.Writer) error { fmt.Fprintf(stdout, "Skipped %d stuck event(s)\n", skipped) if backlog { - fmt.Fprintln(stdout, "Backlog remains — re-run 'relayfile writeback skip-stuck' to continue clearing") + fmt.Fprintf(stdout, "Backlog remains — re-run '%s writeback skip-stuck' to continue clearing\n", programName()) } else { fmt.Fprintln(stdout, "Events cursor caught up to live head") } @@ -5577,7 +5607,7 @@ func runWritebackStatus(args []string, stdout io.Writer) error { return err } if fs.NArg() > 1 { - return errors.New("usage: relayfile writeback status [WORKSPACE] [--json]") + return fmt.Errorf("usage: %s writeback status [WORKSPACE] [--json]", programName()) } workspaceID, record, err := resolveWorkspaceLikeStatus(firstArg(fs)) @@ -5622,7 +5652,7 @@ func runWritebackRetry(args []string, stdout io.Writer) error { return err } if fs.NArg() > 1 { - return errors.New("usage: relayfile writeback retry --op-id OP [WORKSPACE]") + return fmt.Errorf("usage: %s writeback retry --op-id OP [WORKSPACE]", programName()) } op := strings.TrimSpace(*opID) if op == "" { @@ -5707,7 +5737,7 @@ func runOpsList(args []string, stdout io.Writer) error { return err } if fs.NArg() > 0 { - return errors.New("usage: relayfile ops list [--workspace NAME] [--json] [--no-refresh]") + return fmt.Errorf("usage: %s ops list [--workspace NAME] [--json] [--no-refresh]", programName()) } record, err := resolveWorkspaceRecord(strings.TrimSpace(*workspaceName)) if err != nil { @@ -6238,7 +6268,7 @@ func runOpsReplay(args []string, stdin io.Reader, stdout io.Writer) error { return err } if fs.NArg() != 1 { - return errors.New("usage: relayfile ops replay OPID [--workspace NAME]") + return fmt.Errorf("usage: %s ops replay OPID [--workspace NAME]", programName()) } opID := strings.TrimSpace(fs.Arg(0)) if opID == "" { @@ -6298,7 +6328,7 @@ func runPull(args []string, stdout io.Writer) error { return err } if fs.NArg() > 0 { - return errors.New("usage: relayfile pull [--workspace NAME] [--provider PROVIDER] [--reason TEXT]") + return fmt.Errorf("usage: %s pull [--workspace NAME] [--provider PROVIDER] [--reason TEXT]", programName()) } commandClient, err := prepareWorkspaceCommandClient(strings.TrimSpace(*workspaceName), *server, *tokenOverride, defaultJoinScopes) @@ -6370,7 +6400,7 @@ func runWorkspaceCreate(args []string, stdout io.Writer) error { return err } if fs.NArg() != 1 { - return errors.New("usage: relayfile workspace create NAME [--token TOKEN]") + return fmt.Errorf("usage: %s workspace create NAME [--token TOKEN]", programName()) } name := strings.TrimSpace(fs.Arg(0)) @@ -6443,7 +6473,7 @@ func runWorkspaceJoin(args []string, stdout io.Writer) error { return err } if fs.NArg() != 1 { - return errors.New("usage: relayfile workspace join WORKSPACE_ID [--name NAME] [--write]") + return fmt.Errorf("usage: %s workspace join WORKSPACE_ID [--name NAME] [--write]", programName()) } workspaceID := strings.TrimSpace(fs.Arg(0)) @@ -6499,7 +6529,7 @@ func runWorkspaceUse(args []string, stdout io.Writer) error { return err } if fs.NArg() != 1 { - return errors.New("usage: relayfile workspace use NAME") + return fmt.Errorf("usage: %s workspace use NAME", programName()) } if err := ensureAgentRelayCLICompatible(); err != nil { @@ -6755,7 +6785,7 @@ func runWorkspaceViewAdd(args []string, stdout io.Writer) error { return err } if fs.NArg() != 2 { - return errors.New("usage: relayfile workspace view add REMOTE_PATH LOCAL_DIR [--workspace NAME] [--replace]") + return fmt.Errorf("usage: %s workspace view add REMOTE_PATH LOCAL_DIR [--workspace NAME] [--replace]", programName()) } workspaceID, record, err := resolveWorkspaceLikeStatus(strings.TrimSpace(*workspaceName)) if err != nil { @@ -6822,7 +6852,7 @@ func runWorkspaceViewList(args []string, stdout io.Writer) error { return err } if fs.NArg() != 0 { - return errors.New("usage: relayfile workspace view list [--workspace NAME] [--json]") + return fmt.Errorf("usage: %s workspace view list [--workspace NAME] [--json]", programName()) } workspaceID, record, err := resolveWorkspaceLikeStatus(strings.TrimSpace(*workspaceName)) if err != nil { @@ -6846,7 +6876,7 @@ func runWorkspaceViewRemove(args []string, stdout io.Writer) error { return err } if fs.NArg() != 1 { - return errors.New("usage: relayfile workspace view remove LOCAL_DIR [--workspace NAME]") + return fmt.Errorf("usage: %s workspace view remove LOCAL_DIR [--workspace NAME]", programName()) } workspaceID, record, err := resolveWorkspaceLikeStatus(strings.TrimSpace(*workspaceName)) if err != nil { @@ -7051,7 +7081,7 @@ func runWorkspaceStatus(args []string, stdout io.Writer) error { return err } if fs.NArg() > 1 { - return errors.New("usage: relayfile workspace status [--workspace NAME] [--json]") + return fmt.Errorf("usage: %s workspace status [--workspace NAME] [--json]", programName()) } value := strings.TrimSpace(*workspaceName) if value == "" && fs.NArg() == 1 { @@ -7277,7 +7307,7 @@ func runWorkspaceDelete(args []string, stdin io.Reader, stdout io.Writer) error return err } if fs.NArg() != 1 { - return errors.New("usage: relayfile workspace delete NAME [--yes]") + return fmt.Errorf("usage: %s workspace delete NAME [--yes]", programName()) } name := strings.TrimSpace(fs.Arg(0)) @@ -7396,7 +7426,7 @@ func runMount(args []string) error { return fmt.Errorf("invalid --full-pull-min-interval: %w", fullPullIntervalErr) } if fs.NArg() > 2 { - return errors.New("usage: relayfile mount [WORKSPACE] [LOCAL_DIR]") + return fmt.Errorf("usage: %s mount [WORKSPACE] [LOCAL_DIR]", programName()) } localLayoutProvided := false stateFileProvided := false @@ -8058,7 +8088,7 @@ func mountStartBanner(localDir string, interval time.Duration, intervalJitter fl // limitations alongside `relayfile mount`'s flag summary so that // `relayfile mount --help` is self-describing per A13. func printMountHelp(w io.Writer) { - fmt.Fprintln(w, `Usage: relayfile mount [WORKSPACE] [LOCAL_DIR] + fmt.Fprintf(w, `Usage: %[1]s mount [WORKSPACE] [LOCAL_DIR] Mirror a remote workspace to a local directory. The default mode is a synced mirror (--mode=poll): ordinary files on disk that a daemon polls @@ -8107,8 +8137,9 @@ Common flags: --pprof-addr ADDR expose pprof diagnostics, e.g. 127.0.0.1:6060 --memlog-interval 1m log runtime memory stats periodically -See 'relayfile help' for the full command list and -docs/guides/vfs-cloud-setup.md#known-limitations for details.`) +See '%[1]s help' for the full command list and +docs/guides/vfs-cloud-setup.md#known-limitations for details. +`, programName()) } type workspaceCommandClient struct { @@ -8432,11 +8463,11 @@ func listenRunDuplicateKey(evt listenEvent) string { } func printListenUsage(w io.Writer) { - fmt.Fprintln(w, `relayfile listen streams live file events from a workspace and optionally + fmt.Fprintf(w, `%[1]s listen streams live file events from a workspace and optionally runs a command for each matching event. Usage: - relayfile listen [WORKSPACE] [--provider PROVIDER] [--path GLOB] [--event TYPE] [--run CMD] [--format text|json] + %[1]s listen [WORKSPACE] [--provider PROVIDER] [--path GLOB] [--event TYPE] [--run CMD] [--format text|json] Flags: --provider PROVIDER filter to a specific integration (linear, notion, hubspot, …) @@ -8458,90 +8489,91 @@ Flags: Examples: # Stream all events from the default workspace - relayfile listen + %[1]s listen # --- Linear --- # New issue filed anywhere in Linear - relayfile listen --provider linear --event file.created \ + %[1]s listen --provider linear --event file.created \ --run "claude --print 'New Linear issue at {{path}}. Suggest a priority and owner.'" # New issue filed, but only when it lands in the Triage state - relayfile listen --path "/linear/issues/by-state/triage/**" --event file.created \ + %[1]s listen --path "/linear/issues/by-state/triage/**" --event file.created \ --run "claude --print 'Untriaged issue at {{path}}. Assign priority, owner, and cycle.'" # Any In Progress issue updated (catch status changes, description edits, etc.) - relayfile listen --path "/linear/issues/by-state/in-progress/**" --event file.updated \ + %[1]s listen --path "/linear/issues/by-state/in-progress/**" --event file.updated \ --run "claude --print 'In-progress issue changed at {{path}}. Check for blockers.'" # --- GitHub --- # New PR opened on any repo in the org - relayfile listen --path "/github/repos/**/pulls/**" --event file.created \ + %[1]s listen --path "/github/repos/**/pulls/**" --event file.created \ --run "claude --print 'New PR at {{path}}. Write a one-paragraph review summary.'" # New PR labeled needs-review on a specific repo - relayfile listen --path "/github/repos/acme/api/pulls/by-label/needs-review/**" --event file.created \ + %[1]s listen --path "/github/repos/acme/api/pulls/by-label/needs-review/**" --event file.created \ --run "claude --print 'PR needs review at {{path}}. Summarise the diff and flag risks.'" # --- Notion --- # Any page edited across the whole workspace - relayfile listen --provider notion --event file.updated \ + %[1]s listen --provider notion --event file.updated \ --run "claude --print 'Notion page changed at {{path}}. Summarise the update.'" # Edits only inside a specific Notion database - relayfile listen --path "/notion/databases/roadmap/**" --event file.updated \ + %[1]s listen --path "/notion/databases/roadmap/**" --event file.updated \ --run "claude --print 'Roadmap item changed at {{path}}. Send a Slack digest.'" # --- Slack --- # New message in a specific channel - relayfile listen --path "/slack/channels/incidents/**" --event file.created \ + %[1]s listen --path "/slack/channels/incidents/**" --event file.created \ --run "claude --print 'New incident message at {{path}}. Draft a status-page update.'" # --- HubSpot --- # New contact created - relayfile listen --path "/hubspot/contacts/**" --event file.created \ + %[1]s listen --path "/hubspot/contacts/**" --event file.created \ --run "claude --print 'New HubSpot contact at {{path}}. Draft a personalised intro email.'" # Deal moved to a new stage - relayfile listen --path "/hubspot/deals/**" --event file.updated \ + %[1]s listen --path "/hubspot/deals/**" --event file.updated \ --run "claude --print 'Deal updated at {{path}}. Draft a follow-up for the new stage.'" # --- Asana --- # New task in a specific project - relayfile listen --path "/asana/projects/q3-launch/**" --event file.created \ + %[1]s listen --path "/asana/projects/q3-launch/**" --event file.created \ --run "claude --print 'New task in Q3 launch at {{path}}. Break it into subtasks.'" # --- Shortcut --- # New story under a specific epic - relayfile listen --path "/shortcut/stories/by-epic/payments/**" --event file.created \ + %[1]s listen --path "/shortcut/stories/by-epic/payments/**" --event file.created \ --run "claude --print 'New payments story at {{path}}. Suggest an implementation approach.'" # --- Granola / Fathom --- # New meeting notes → extract action items - relayfile listen --provider granola --event file.created \ + %[1]s listen --provider granola --event file.created \ --run "claude --print 'New meeting notes at {{path}}. Extract action items and owners.'" # New Fathom call recording → follow-up email - relayfile listen --provider fathom --event file.created \ + %[1]s listen --provider fathom --event file.created \ --run "claude --print 'New call at {{path}}. Write a follow-up email with key decisions.'" # --- Scripting --- # Print raw JSON events for piping - relayfile listen --provider linear --format json | jq '.path' + %[1]s listen --provider linear --format json | jq '.path' The workspace tree has alias views (by-state/, by-label/, by-epic/, by-name/, by-id/, …) -for every provider. Run 'relayfile tree / --depth 3' to explore what's available. +for every provider. Run '%[1]s tree / --depth 3' to explore what's available. Want this running headlessly for your whole team — turning issues into reviewed PRs automatically? -See https://github.com/AgentWorkforce/factory`) +See https://github.com/AgentWorkforce/factory +`, programName()) } // runDev is the zero-friction entry point for reactive local agents. @@ -8567,15 +8599,15 @@ func runDev(args []string, stdin io.Reader, stdout io.Writer) error { provider = "linear" } fmt.Fprintln(stdout, "Not connected to Agent Relay. Get started with:") - fmt.Fprintf(stdout, "\n relayfile setup --provider %s\n\n", provider) - fmt.Fprintln(stdout, "Then re-run: relayfile dev "+strings.Join(args, " ")) + fmt.Fprintf(stdout, "\n %s setup --provider %s\n\n", programName(), provider) + fmt.Fprintf(stdout, "Then re-run: %s dev %s\n", programName(), strings.Join(args, " ")) return err } fmt.Fprintf(stdout, "Workspace: %s\n", commandClient.workspaceID) if p := strings.TrimSpace(*providerPeek); p != "" { fmt.Fprintf(stdout, "Provider filter: %s\n", p) - fmt.Fprintf(stdout, "Tip: run 'relayfile integration list' to see connected providers.\n") + fmt.Fprintf(stdout, "Tip: run '%s integration list' to see connected providers.\n", programName()) } fmt.Fprintln(stdout) @@ -8724,7 +8756,7 @@ func runListen(args []string, stdout io.Writer) error { if runCmd == "" && format == "text" { fmt.Fprintln(stdout, "Tip: pass --run to execute a command per event.") fmt.Fprintln(stdout, " See 'relayfile help listen' for examples with Linear, Notion, HubSpot, and more.") - fmt.Fprintln(stdout, " Add --background to detach; 'relayfile supervisor install' to survive reboots.") + fmt.Fprintf(stdout, " Add --background to detach; '%s supervisor install' to survive reboots.\n", programName()) } fmt.Fprintln(stdout) } @@ -9008,34 +9040,35 @@ func runListenCommand(rootCtx context.Context, runCmd string, evt listenEvent, r } func printSupervisorUsage(w io.Writer) { - fmt.Fprintln(w, `relayfile supervisor manages the listen daemon as a system service. + fmt.Fprintf(w, `%[1]s supervisor manages the listen daemon as a system service. On Linux it writes a systemd user unit (~/.config/systemd/user/relayfile-listen.service). On macOS it writes a launchd agent (~/Library/LaunchAgents/com.relayfile.listen.plist). Usage: - relayfile supervisor install [LISTEN_FILTERS...] install and start the service - relayfile supervisor uninstall stop, disable, and remove the service - relayfile supervisor status show service status + %[1]s supervisor install [LISTEN_FILTERS...] install and start the service + %[1]s supervisor uninstall stop, disable, and remove the service + %[1]s supervisor status show service status Examples: # Install: react to every new Linear triage issue - relayfile supervisor install \ + %[1]s supervisor install \ --path "/linear/issues/by-state/triage/**" --event file.created \ --run "claude --print 'New triage issue at {{path}}. Assign it.'" # Install: all Linear events, background agent - relayfile supervisor install --provider linear --run "my-agent --event '{{event}}'" + %[1]s supervisor install --provider linear --run "my-agent --event '{{event}}'" - relayfile supervisor status - relayfile supervisor uninstall + %[1]s supervisor status + %[1]s supervisor uninstall -The filters accepted by 'relayfile listen' — --server, --token, --provider, +The filters accepted by '%[1]s listen' — --server, --token, --provider, --path, --event, --run, --format — are accepted here and embedded verbatim into the unit file. Its process-model flags (--background, --daemonized) are not: the service is what keeps the listener running, so a unit that detached -would exit on every start. The service restarts automatically on failure.`) +would exit on every start. The service restarts automatically on failure. +`, programName()) } const ( @@ -9346,7 +9379,7 @@ func runTree(args []string, stdout io.Writer) error { return err } if fs.NArg() > 2 { - return errors.New("usage: relayfile tree [WORKSPACE] [PATH] [--depth N]") + return fmt.Errorf("usage: %s tree [WORKSPACE] [PATH] [--depth N]", programName()) } remotePath := strings.TrimSpace(*pathFlag) @@ -9494,7 +9527,7 @@ func runRead(args []string, stdout io.Writer) error { return err } if fs.NArg() < 1 || fs.NArg() > 2 { - return errors.New("usage: relayfile read [WORKSPACE] PATH") + return fmt.Errorf("usage: %s read [WORKSPACE] PATH", programName()) } var workspaceValue string @@ -9563,7 +9596,7 @@ func runSeed(args []string, stdout io.Writer) error { return err } if fs.NArg() > 2 { - return errors.New("usage: relayfile seed [WORKSPACE] [DIR]") + return fmt.Errorf("usage: %s seed [WORKSPACE] [DIR]", programName()) } creds, err := loadCredentials() @@ -9638,7 +9671,7 @@ func runExport(args []string, stdout io.Writer) error { return err } if fs.NArg() > 1 { - return errors.New("usage: relayfile export [WORKSPACE] --format FORMAT [--output FILE]") + return fmt.Errorf("usage: %s export [WORKSPACE] --format FORMAT [--output FILE]", programName()) } workspaceValue := "" @@ -9683,7 +9716,7 @@ func runStatus(args []string, stdout io.Writer) error { return err } if fs.NArg() > 1 { - return errors.New("usage: relayfile status [WORKSPACE] [--json]") + return fmt.Errorf("usage: %s status [WORKSPACE] [--json]", programName()) } workspaceValue := "" @@ -9910,7 +9943,7 @@ func runStop(args []string, stdout io.Writer) error { return err } if fs.NArg() > 1 { - return errors.New("usage: relayfile stop [WORKSPACE]") + return fmt.Errorf("usage: %s stop [WORKSPACE]", programName()) } record, err := resolveWorkspaceRecord(firstArg(fs)) if err != nil { @@ -9938,13 +9971,13 @@ func runRestart(args []string, stdout io.Writer) error { "foreground": false, })); err != nil { if errors.Is(err, flag.ErrHelp) { - fmt.Fprintln(stdout, "usage: relayfile restart [WORKSPACE] [--foreground]") + fmt.Fprintf(stdout, "usage: %s restart [WORKSPACE] [--foreground]\n", programName()) return nil } return err } if fs.NArg() > 1 { - return errors.New("usage: relayfile restart [WORKSPACE] [--foreground]") + return fmt.Errorf("usage: %s restart [WORKSPACE] [--foreground]", programName()) } record, err := resolveWorkspaceRecord(firstArg(fs)) @@ -10039,7 +10072,7 @@ func runLogs(args []string, stdout io.Writer) error { return err } if fs.NArg() > 1 { - return errors.New("usage: relayfile logs [WORKSPACE] [--lines N]") + return fmt.Errorf("usage: %s logs [WORKSPACE] [--lines N]", programName()) } record, err := resolveWorkspaceRecord(firstArg(fs)) if err != nil { @@ -10075,7 +10108,7 @@ func runObserver(args []string, stdout io.Writer) error { return err } if fs.NArg() > 1 { - return errors.New("usage: relayfile observer [WORKSPACE] [--no-open]") + return fmt.Errorf("usage: %s observer [WORKSPACE] [--no-open]", programName()) } workspaceValue := "" @@ -11495,7 +11528,7 @@ func loadCredentials() (credentials, error) { payload, err := os.ReadFile(credentialsPath()) if err != nil { if errors.Is(err, os.ErrNotExist) { - return creds, fmt.Errorf("credentials not found at %s; run relayfile login --api-key for self-hosted credentials or pass --token", credentialsPath()) + return creds, fmt.Errorf("credentials not found at %s; run %s login --api-key for self-hosted credentials or pass --token", credentialsPath(), programName()) } return creds, err } @@ -14181,12 +14214,13 @@ func waitForBackgroundMountRegistration(pidFile, localDir string, childPID int, logPath = registeredState.LogFile } return fmt.Errorf( - "background mount process %d did not register daemon state within %s for %s; child may still be initializing (pid file: %s, log: %s). If it remains stuck, run `relayfile stop %s` to release the mount", + "background mount process %d did not register daemon state within %s for %s; child may still be initializing (pid file: %s, log: %s). If it remains stuck, run `%s stop %s` to release the mount", childPID, backgroundMountRegistrationTimeout, localDir, pidFile, logPath, + programName(), workspaceNameForLocalDir(localDir), ) } diff --git a/cmd/relayfile-cli/program_name_test.go b/cmd/relayfile-cli/program_name_test.go new file mode 100644 index 00000000..cd59f844 --- /dev/null +++ b/cmd/relayfile-cli/program_name_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "bytes" + "regexp" + "strings" + "testing" +) + +// commandMention matches guidance naming the relayfile binary as a command to +// run — "relayfile mount", "relayfile supervisor install". It deliberately +// requires a space, so the systemd unit name (relayfile-listen.service) and the +// launchd label (com.relayfile.listen) do not match: those are real filenames +// and must stay literal whoever is invoking us. +// +// A bare `relayfile` on its own in a usage block counts too: one such line +// survived a pass that only looked for `relayfile `. +var commandMention = regexp.MustCompile(`\brelayfile(?: [a-z]|\s*$|\s{2,})`) + +// Nouns, not instructions. "delegated relayfile credentials" describes what is +// missing; it tells nobody to run anything. +var allowedNouns = []string{"relayfile credentials", "relayfile workspace id"} + +func withoutAllowedNouns(text string) string { + for _, noun := range allowedNouns { + text = strings.ReplaceAll(text, noun, "") + } + return text +} + +// TestUsageNamesTheInvokingProgram pins the contract behind relayfile#509: +// mounted as `agent-relay file`, nothing may tell the user to run `relayfile`, +// because that binary is not installed for them. +// +// Checked over the usage printers rather than one message, since the leak was +// never in one place: an earlier fix corrected five call sites found by +// grepping for "run relayfile", and review found dozens more phrased +// differently. A pattern is the only thing that catches the next one. +func TestUsageNamesTheInvokingProgram(t *testing.T) { + t.Setenv(programNameEnv, "agent-relay file") + + printers := map[string]func(*bytes.Buffer){ + "usage": func(b *bytes.Buffer) { printUsage(b) }, + "listen": func(b *bytes.Buffer) { printListenUsage(b) }, + "supervisor": func(b *bytes.Buffer) { printSupervisorUsage(b) }, + "workspace": func(b *bytes.Buffer) { printWorkspaceUsage(b, "") }, + "integration": func(b *bytes.Buffer) { + printIntegrationUsage(b, "") + }, + "ops": func(b *bytes.Buffer) { printOpsUsage(b, "") }, + "writeback": func(b *bytes.Buffer) { printWritebackUsage(b, "") }, + "digest": func(b *bytes.Buffer) { printDigestUsage(b, "") }, + } + + for name, print := range printers { + t.Run(name, func(t *testing.T) { + var out bytes.Buffer + print(&out) + if found := commandMention.FindString(withoutAllowedNouns(out.String())); found != "" { + t.Errorf("%s usage tells a mounted user to run %q; use programName()", name, found) + } + if !strings.Contains(out.String(), "agent-relay file") { + t.Errorf("%s usage never names the invoking program", name) + } + }) + } +} + +// TestUsageKeepsServiceFileNames guards the other direction: the systemd unit +// and launchd label are filenames on disk, identical for every caller, and a +// blanket rename would have broken them. +func TestUsageKeepsServiceFileNames(t *testing.T) { + t.Setenv(programNameEnv, "agent-relay file") + var out bytes.Buffer + printSupervisorUsage(&out) + for _, literal := range []string{"relayfile-listen.service", "com.relayfile.listen.plist"} { + if !strings.Contains(out.String(), literal) { + t.Errorf("supervisor usage no longer names %s; that is a real path, not a command", literal) + } + } +} + +// TestUsageDefaultsToRelayfile keeps direct users seeing the name they typed. +func TestUsageDefaultsToRelayfile(t *testing.T) { + t.Setenv(programNameEnv, "") + var out bytes.Buffer + printUsage(&out) + if !strings.Contains(out.String(), "relayfile ") { + t.Error("unmounted usage should name relayfile") + } + if strings.Contains(out.String(), "agent-relay file") { + t.Error("unmounted usage must not name the host") + } +} diff --git a/cmd/relayfile-mount/backpressure_yield_test.go b/cmd/relayfile-mount/backpressure_yield_test.go new file mode 100644 index 00000000..9a1d1092 --- /dev/null +++ b/cmd/relayfile-mount/backpressure_yield_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/agentworkforce/relayfile/internal/mountsync" +) + +// A busy workspace answers 429 workspace_busy with an advertised Retry-After. +// Classifying that as a cycle FAILURE made the first cycle of an initial +// bootstrap fatal, so the run died roughly 35s into a 210s budget having synced +// zero files — surfacing to Cloud as BootstrapFailedError. That accounted for 50 +// of 62 proactive mount-bootstrap failures over three days in production. The +// retry budget already existed; the cycle only had to yield so the ticker could +// use it. +func TestBackpressureIsAYieldNotACycleFailure(t *testing.T) { + busy := &mountsync.HTTPError{ + StatusCode: http.StatusTooManyRequests, + Code: "workspace_busy", + Message: "workspace durable object is busy; retry after the advertised delay", + } + + if !isBackpressureError(busy) { + t.Fatalf("429 workspace_busy must be recognised as backpressure") + } + if !cycleYielded(&cycleOutcomeError{cause: busy, yielded: true}) { + t.Fatalf("a backpressure cycle must report as yielded so the bootstrap resumes") + } + // Wrapped the way the syncer actually returns it. + if !isBackpressureError(fmt.Errorf("mount sync cycle: %w", busy)) { + t.Fatalf("backpressure must be detected through error wrapping") + } +} + +// Deliberately narrow. A 5xx is the server being BROKEN, not busy, and a +// context deadline is handled by its own pre-existing branch. Treating either +// as backpressure would let a genuinely unhealthy backend look like a queue and +// spin the bootstrap for its whole budget instead of failing loudly. +func TestOnlyTooManyRequestsCountsAsBackpressure(t *testing.T) { + for _, tc := range []struct { + name string + err error + }{ + {"500", &mountsync.HTTPError{StatusCode: http.StatusInternalServerError, Message: "boom"}}, + {"503", &mountsync.HTTPError{StatusCode: http.StatusServiceUnavailable, Message: "down"}}, + {"403", &mountsync.HTTPError{StatusCode: http.StatusForbidden, Message: "nope"}}, + {"deadline", context.DeadlineExceeded}, + {"plain", errors.New("something else")}, + } { + t.Run(tc.name, func(t *testing.T) { + if isBackpressureError(tc.err) { + t.Fatalf("%v must not be treated as server backpressure", tc.err) + } + }) + } +} + +// Devin and Cursor both flagged this independently on PR #511, and they were +// right: marking a 429 `yielded` is only half the story. finishInitialBootstrap +// treats a yielded first cycle with nothing in progress as a COMPLETED +// bootstrap, because the only other thing that yields — a per-cycle deadline — +// cannot reach that state without a persisted checkpoint. A 429 can: it may +// arrive before the very first saveState. Falling through would exit 0 and hand +// Cloud an empty mirror it believes is fully synced, which is worse than the +// hard failure this PR set out to fix, because it fails silently. +func TestColdBackpressureIsNotReportedAsACompletedBootstrap(t *testing.T) { + busy := &cycleOutcomeError{ + cause: &mountsync.HTTPError{ + StatusCode: http.StatusTooManyRequests, + Code: "workspace_busy", + Message: "workspace durable object is busy; retry after the advertised delay", + }, + yielded: true, + backpressure: true, + } + + if !cycleYielded(busy) { + t.Fatalf("a 429 must still yield, so it is not a terminal cycle failure") + } + if !cycleBackpressure(busy) { + t.Fatalf("a 429 yield must be distinguishable from a deadline yield") + } + + // A deadline yield must NOT be mistaken for backpressure: it reaches + // finishInitialBootstrap only with a checkpoint on disk, where completing is + // the correct outcome. + deadline := &cycleOutcomeError{cause: context.DeadlineExceeded, yielded: true} + if cycleBackpressure(deadline) { + t.Fatalf("a deadline yield must not be treated as server backpressure") + } + if !cycleYielded(deadline) { + t.Fatalf("a deadline yield must still count as yielded") + } +} + +// The resumable outcome is what makes the cold case safe: it exits +// initialBootstrapIncompleteExitCode so the caller reruns us, rather than +// exit 1 (fatal, the original bug) or exit 0 (silently empty, the regression). +func TestResumableIncompleteExitsRetryableUnderOnce(t *testing.T) { + resumable := newResumableInitialBootstrapIncompleteError( + bootstrapResumeState{}, + "initial cycle yielded to server backpressure before any bootstrap progress", + errors.New("http 429 workspace_busy"), + ) + + if got := mountProcessExitCode(mountConfig{once: true}, resumable); got != initialBootstrapIncompleteExitCode { + t.Fatalf("cold backpressure must exit retryable, got %d", got) + } + terminal := newInitialBootstrapIncompleteError( + bootstrapResumeState{}, + "initial cycle failed", + errors.New("http 500"), + ) + if got := mountProcessExitCode(mountConfig{once: true}, terminal); got != 1 { + t.Fatalf("a real failure must stay fatal, got %d", got) + } +} diff --git a/cmd/relayfile-mount/main.go b/cmd/relayfile-mount/main.go index f2e51f0f..ea7a8545 100644 --- a/cmd/relayfile-mount/main.go +++ b/cmd/relayfile-mount/main.go @@ -9,6 +9,7 @@ import ( "fmt" "log" "math/rand" + "net/http" "os" "os/signal" "path/filepath" @@ -697,6 +698,21 @@ func runSinglePollingMount(rootCtx context.Context, cfg mountConfig) error { return nil } } + // Server-advertised backpressure is a YIELD, not a cycle + // failure. The workspace Durable Object is single-threaded, so a + // busy workspace answers 429 workspace_busy with a Retry-After and + // means "come back shortly" — the mirror is fine, the server is + // saturated. Treating it as terminal made the FIRST cycle of an + // initial bootstrap fatal: the run died ~35s into a 210s budget + // having synced 0 files, surfacing to Cloud as BootstrapFailedError. + // That was 81% of proactive mount-bootstrap failures in production + // (50 of 62 over three days). The retry budget is already there; + // the cycle just has to let the ticker use it. + if isBackpressureError(err) { + lastCycleErr = &cycleOutcomeError{cause: err, yielded: true, backpressure: true} + log.Printf("mount sync cycle yielded to server backpressure (will retry): %v", err) + return nil + } lastCycleErr = &cycleOutcomeError{cause: err} log.Printf("mount sync cycle failed: %v", err) return nil @@ -1043,6 +1059,23 @@ func finishInitialBootstrap(rootCtx context.Context, cfg mountConfig, run func(r if rootErr := rootCtx.Err(); rootErr != nil { return newResumableInitialBootstrapIncompleteError(state, "context cancelled after yielded initial cycle", rootErr) } + // A deadline yield reaching here means the bootstrap finished + // (that branch only yields once a checkpoint exists), so the + // fall-through to success below is right for it. A 429 is NOT + // that: it can arrive before the first saveState, leaving + // nothing on disk and nothing synced. Falling through would + // exit 0 and tell Cloud an EMPTY mirror was bootstrapped — + // strictly worse than the hard failure this change set out to + // fix, because it fails silently. Report it as resumable + // instead, which exits initialBootstrapIncompleteExitCode and + // asks the caller to run us again. + if cycleBackpressure(err) { + return newResumableInitialBootstrapIncompleteError( + state, + "initial cycle yielded to server backpressure before any bootstrap progress", + err, + ) + } } else { return newInitialBootstrapIncompleteError(state, "initial cycle failed", err) } @@ -1138,6 +1171,14 @@ type bootstrapResumeState struct { type cycleOutcomeError struct { cause error yielded bool + // backpressure marks a yield caused by the SERVER asking us to slow down + // (HTTP 429) rather than by this process running out of per-cycle time. + // The two need different completion handling: a deadline yield only ever + // fires once a bootstrap checkpoint exists, so "not in progress" genuinely + // means finished, whereas a 429 can arrive before anything at all has been + // persisted — and reporting THAT as a completed bootstrap hands Cloud an + // empty mirror it believes is fully synced. + backpressure bool } func (e *cycleOutcomeError) Error() string { @@ -1181,6 +1222,22 @@ func newInitialBootstrapIncompleteErrorWithResumable(state bootstrapResumeState, } } +// isBackpressureError reports a server telling us to slow down rather than a +// sync that went wrong. Deliberately narrow: only HTTP 429. A 5xx is the server +// being broken, not busy, and must stay a real cycle failure so a genuinely +// unhealthy backend is not mistaken for a queue. +func isBackpressureError(err error) bool { + var httpErr *mountsync.HTTPError + return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusTooManyRequests +} + +// cycleBackpressure reports a yield that came from a server 429 rather than a +// per-cycle deadline. See the field comment on cycleOutcomeError. +func cycleBackpressure(err error) bool { + var outcome *cycleOutcomeError + return errors.As(err, &outcome) && outcome.backpressure +} + func cycleYielded(err error) bool { var outcome *cycleOutcomeError return errors.As(err, &outcome) && outcome.yielded diff --git a/packages/sdk/typescript/src/relay-cli/binary-output.test.ts b/packages/sdk/typescript/src/relay-cli/binary-output.test.ts index 79bbb4d9..4b151d5b 100644 --- a/packages/sdk/typescript/src/relay-cli/binary-output.test.ts +++ b/packages/sdk/typescript/src/relay-cli/binary-output.test.ts @@ -126,7 +126,11 @@ describe("stdout is byte-exact", () => { const throughSurface = await invoke(realBinDir, argv) const direct = spawnSync( path.join(realBinDir, process.platform === "win32" ? "relayfile.exe" : "relayfile"), - argv + argv, + // The surface sets this, so the baseline must too: the comparison is + // about bytes surviving the stdio path, not about how the binary names + // itself, which mounted output deliberately changes (relayfile#509). + { env: { ...process.env, RELAYFILE_PROGRAM_NAME: "agent-relay file" } } ) expect(throughSurface.code).toBe(direct.status) diff --git a/packages/sdk/typescript/src/relay-cli/index.ts b/packages/sdk/typescript/src/relay-cli/index.ts index bb63adac..35e1edc7 100644 --- a/packages/sdk/typescript/src/relay-cli/index.ts +++ b/packages/sdk/typescript/src/relay-cli/index.ts @@ -186,6 +186,12 @@ function exitCodeForSignal(signal: NodeJS.Signals): number { * @param options - Optional overrides for binary lookup, env, and cwd. * @returns A surface satisfying `@agent-relay/cli-surface`'s `RelayCliSurface`. */ +/** + * How users reach this binary when it is mounted, for messages that instruct + * them to run something. Matches the group name the host registers. + */ +const MOUNTED_PROGRAM_NAME = "agent-relay file" + export function createRelayCliSurface( options: CreateRelayCliSurfaceOptions = {} ): RelayCliSurface { @@ -268,7 +274,12 @@ export function createRelayCliSurface( // signal handlers are installed: the host owns them. const child = spawn(command, childArgs, { cwd, - env, + // The binary writes messages that tell users to run something. It + // has no way to know it was reached through a host, so left alone it + // says "run relayfile login", naming a binary someone who installed + // `agent-relay` does not have. Telling it how it was invoked keeps + // that advice followable; unset, direct users still see `relayfile`. + env: { ...env, RELAYFILE_PROGRAM_NAME: MOUNTED_PROGRAM_NAME }, stdio: ["inherit", "pipe", "pipe"] }) diff --git a/packages/sdk/typescript/src/relay-cli/mount-routing.test.ts b/packages/sdk/typescript/src/relay-cli/mount-routing.test.ts index 2dec6831..d235b54a 100644 --- a/packages/sdk/typescript/src/relay-cli/mount-routing.test.ts +++ b/packages/sdk/typescript/src/relay-cli/mount-routing.test.ts @@ -43,8 +43,22 @@ interface Capture { stderr: string } +/** + * Env for a direct binary spawn used as the comparison baseline. + * + * RELAYFILE_PROGRAM_NAME matches what the surface sets, because these tests + * assert that argv *routes* identically — not that the binary names itself + * identically. Mounted, it deliberately says `agent-relay file` so its advice + * points at a binary the user actually has (relayfile#509). Without this the + * comparison fails on the one difference the mount is supposed to make. + */ function childEnv(): NodeJS.ProcessEnv { - return { ...process.env, HOME: home, USERPROFILE: home } + return { + ...process.env, + HOME: home, + USERPROFILE: home, + RELAYFILE_PROGRAM_NAME: "agent-relay file" + } } async function throughSurface(argv: readonly string[]): Promise { diff --git a/packages/sdk/typescript/src/relay-cli/program-name.test.ts b/packages/sdk/typescript/src/relay-cli/program-name.test.ts new file mode 100644 index 00000000..42fd23f9 --- /dev/null +++ b/packages/sdk/typescript/src/relay-cli/program-name.test.ts @@ -0,0 +1,66 @@ +import { chmodSync, mkdirSync, writeFileSync } from "node:fs" +import path from "node:path" + +import { describe, expect, it } from "vitest" + +import { createRelayCliSurface } from "./index.js" +import { temporaryDirectory } from "./testing/build-binary.js" + +/** + * The binary writes messages that tell users to run something: + * + * credentials not found at …; run relayfile login --api-key … + * + * Reached through `agent-relay file`, that names a binary the user does not + * have — they installed `agent-relay`. The binary cannot know it was mounted, + * so the surface tells it, and the Go side formats those messages with the + * name it is given (relayfile#509). + * + * Asserted against a real spawn rather than a stubbed one, because the value + * has to survive the env the surface actually builds. + */ +function echoEnvBinDir(): string { + const binDir = path.join(temporaryDirectory("program-name"), "bin") + mkdirSync(binDir, { recursive: true }) + const script = path.join(binDir, "echo-env.mjs") + writeFileSync( + script, + `process.stdout.write(process.env.RELAYFILE_PROGRAM_NAME ?? "")\n` + ) + const shim = path.join(binDir, "relayfile") + writeFileSync( + shim, + `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(script)}\n` + ) + chmodSync(shim, 0o755) + return binDir +} + +async function programNameSeenByBinary( + env?: NodeJS.ProcessEnv +): Promise { + const chunks: Buffer[] = [] + await createRelayCliSurface({ + resolve: { binDirs: [echoEnvBinDir()] }, + skipCloudPreflight: true, + ...(env ? { env } : {}) + }).run(["status"], { + stdout: (chunk) => + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk)), + stderr: () => {} + }) + return Buffer.concat(chunks).toString("utf8") +} + +describe("mounted program name", () => { + it("tells the binary it was reached through agent-relay file", async () => { + expect(await programNameSeenByBinary()).toBe("agent-relay file") + }) + + it("sets it even when the caller supplies its own env", async () => { + // A host passing `env` must not accidentally drop the name and send users + // back to a binary they do not have. + const seen = await programNameSeenByBinary({ PATH: process.env["PATH"] ?? "" }) + expect(seen).toBe("agent-relay file") + }) +}) diff --git a/packages/sdk/typescript/src/relay-cli/surface.test.ts b/packages/sdk/typescript/src/relay-cli/surface.test.ts index 83b80d1b..4b1bbad0 100644 --- a/packages/sdk/typescript/src/relay-cli/surface.test.ts +++ b/packages/sdk/typescript/src/relay-cli/surface.test.ts @@ -183,7 +183,9 @@ describe("run", () => { // rather than trip the unknown-command guard. const result = await invoke(["--help"]) expect(result.code).toBe(0) - expect(result.stdout).toContain("relayfile is the RelayFile CLI") + // Mounted, the binary names the host rather than itself (relayfile#509), + // so this also proves the program name reached the child. + expect(result.stdout).toContain("agent-relay file is the RelayFile CLI") }) it("writes only through the injected io", async () => {