From bca3d8c19c0fbfb87843db38a144c993817435a5 Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Fri, 18 Sep 2026 16:05:13 -0700 Subject: [PATCH 1/5] fix(cli): name the host in messages when mounted as agent-relay file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #509. `agent-relay file` mounts this binary through @relayfile/sdk's Relay CLI surface. Someone who arrived that way installed `agent-relay`, not `relayfile`, so six messages sent them to a binary they do not have: $ agent-relay file writeback status error: credentials not found at ~/.relayfile/credentials.json; run relayfile login --api-key for self-hosted credentials … Help never had this problem — it is rendered from the declared command spec rather than forwarded, so `agent-relay file --help` cannot print `Usage: relayfile …`. Error text is written by the Go binary at runtime and was not covered by that. The binary cannot know how it was reached, so the surface tells it: RELAYFILE_PROGRAM_NAME, set on the child env, read by programName(), which falls back to "relayfile" when unset or blank. Direct users keep seeing the name they typed. Fixed at all five user-facing sites rather than only the reachable two, so a message that becomes reachable later is already correct. The test drives a real spawn through the real resolver with a fixture binary that echoes the variable back, rather than stubbing the spawn: the value has to survive the env the surface actually builds, including when a caller supplies its own. Mutation-verified — dropping the env fails both. Co-Authored-By: Claude Opus 5 (1M context) Session-Id: d458bd97-53d8-4f02-be9c-48b67b93c916 --- cmd/relayfile-cli/main.go | 33 ++++++++-- .../sdk/typescript/src/relay-cli/index.ts | 13 +++- .../src/relay-cli/program-name.test.ts | 66 +++++++++++++++++++ 3 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 packages/sdk/typescript/src/relay-cli/program-name.test.ts diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index 7a8e6de6..2105899c 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 @@ -119,13 +137,14 @@ func (e *agentRelayMessagingOnlyWorkspaceError) Error() string { setupCommand := "relayfile 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(), ) } @@ -1518,8 +1537,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 +2618,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 @@ -11495,7 +11515,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 +14201,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/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/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") + }) +}) From 0f03ead9205086d8488160e52ecfd3bddbd9a35f Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Fri, 18 Sep 2026 16:52:36 -0700 Subject: [PATCH 2/5] fix(cli): route every mounted command hint through programName() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the previous commit's fix was a fraction of the problem. It corrected five sites located by grepping for "run relayfile"; Devin and Bugbot both pointed out that the same advice appears in other phrasings — usage lines, setup completion, integration connect, dev guidance, the supervisor help block, and the nameless branch of the messaging-only error, which still hardcoded "relayfile setup" while the same sentence's login advice used programName(). The lesson is that a grep for one phrasing cannot bound this. So: - 44 `Usage: relayfile …` prints and 38 `usage: relayfile …` errors now format through programName() - 8 multi-line raw-string help blocks templated, including the top-level usage, `listen`, and `supervisor` - the remaining instruction sites: setup completion, integration connect, dev guidance, writeback stuck guidance, the login deprecation warning, and the empty-name setup branch Deliberately untouched, because they are not instructions: - `relayfile-listen.service` and `com.relayfile.listen.plist` are real paths on disk, identical whoever invokes us; a blanket rename would have broken them - "delegated relayfile credentials" and "relayfile workspace id" are nouns describing what is missing The test asserts the class rather than any message. It runs every usage printer with the host name set and fails on a pattern, because the leak was never in one place. It also pins the two service filenames, and that a direct user still sees "relayfile". That test immediately found five more leaks than review had named, and then a sixth: a bare `relayfile` with no verb, in the top-level usage, which both my pattern and the test's first pattern missed. The test regex now covers a bare mention too. Mutation-verified in both directions. Co-Authored-By: Claude Opus 5 (1M context) Session-Id: d458bd97-53d8-4f02-be9c-48b67b93c916 --- cmd/relayfile-cli/main.go | 451 +++++++++++++------------ cmd/relayfile-cli/program_name_test.go | 94 ++++++ 2 files changed, 326 insertions(+), 219 deletions(-) create mode 100644 cmd/relayfile-cli/program_name_test.go diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index 2105899c..6ce5dd91 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -134,7 +134,7 @@ 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 = programName() + " setup --workspace " + strconv.Quote(name) @@ -151,7 +151,7 @@ func (e *agentRelayMessagingOnlyWorkspaceError) Error() string { 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 { @@ -719,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": @@ -735,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: @@ -784,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()) } } @@ -868,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()) } } @@ -899,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 @@ -992,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 { @@ -1033,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), "/") @@ -1178,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 } @@ -2984,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 @@ -3014,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 { @@ -3250,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 { @@ -3259,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), @@ -3295,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 { @@ -3395,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 { @@ -3448,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 == "" { @@ -3514,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) @@ -3564,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 } @@ -3736,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, @@ -3772,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 { @@ -3894,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) { @@ -4048,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)) @@ -4113,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 { @@ -4234,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 { @@ -4872,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)) @@ -5515,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") @@ -5580,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") } @@ -5597,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)) @@ -5642,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 == "" { @@ -5727,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 { @@ -6258,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 == "" { @@ -6318,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) @@ -6390,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)) @@ -6463,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)) @@ -6519,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 { @@ -6775,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 { @@ -6842,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 { @@ -6866,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 { @@ -7071,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 { @@ -7297,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)) @@ -7416,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 @@ -8078,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 @@ -8127,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 { @@ -8452,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, …) @@ -8478,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. @@ -8587,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) @@ -8744,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) } @@ -9028,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 ( @@ -9366,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) @@ -9514,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 @@ -9583,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() @@ -9658,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 := "" @@ -9703,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 := "" @@ -9930,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 { @@ -9958,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)) @@ -10059,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 { @@ -10095,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 := "" 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") + } +} From 167a64d9579a9e0c0ff29229b2d100c25e47d9d5 Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Fri, 18 Sep 2026 16:59:39 -0700 Subject: [PATCH 3/5] test(sdk): compare routing, not the name the binary calls itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three suites spawn the binary directly and compare it to the surface. They exist to prove argv routes through unchanged — but they used whole-output equality as the proxy, so they failed on the one difference the mount is supposed to make: mounted, the binary names the host rather than itself. expected 'Usage: agent-relay file mount [WORKSP…' to be 'Usage: relayfile mount [WORKSPACE] [L…' Give the baseline spawn the same RELAYFILE_PROGRAM_NAME the surface sets, so the comparison isolates routing again. The surface test that asserted the top-level banner now expects the mounted name, which makes it a stronger check: it proves both that a leading flag reaches the binary and that the program name arrived with it. Not fixed here, and not caused by this branch: client.test.ts's "ErrorEvent is undefined (Node)" fails identically on origin/main, verified in a clean worktree. Co-Authored-By: Claude Opus 5 (1M context) Session-Id: d458bd97-53d8-4f02-be9c-48b67b93c916 --- .../src/relay-cli/binary-output.test.ts | 6 +++++- .../src/relay-cli/mount-routing.test.ts | 16 +++++++++++++++- .../sdk/typescript/src/relay-cli/surface.test.ts | 4 +++- 3 files changed, 23 insertions(+), 3 deletions(-) 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/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/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 () => { From 0ec590917ab6a3c8a26ef835805b1152a2779291 Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Fri, 18 Sep 2026 17:53:58 -0700 Subject: [PATCH 4/5] fix(mount): treat 429 backpressure as a yield, not a terminal cycle failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace Durable Object is single-threaded per workspace, so a busy one answers `429 workspace_busy` with an advertised Retry-After. That is the server saying "come back shortly" — the mirror is healthy, the backend is saturated. The cycle classifier only ever marked `context.DeadlineExceeded` as a yield. Everything else, 429 included, fell through to a plain cycleOutcomeError, and a non-yielded error makes the initial bootstrap terminal. So the first cycle of a cold mount that met a busy workspace died immediately: mount sync cycle failed: http 429 workspace_busy: workspace durable object is busy; retry after the advertised delay failed to start poll mount: initial bootstrap incomplete: initial cycle failed (0 files synced (authoritative total unavailable)): http 429 workspace_busy In production that surfaced to Cloud as BootstrapFailedError at elapsedMs≈35000 against budgetSeconds=210 with progressing=false — not a timeout, a refusal to wait. It accounted for 50 of 62 proactive mount-bootstrap failures over three days, which in turn were ~half of all proactive run failures. Cloud-side callers were taught to honour this exact backpressure over AgentWorkforce/cloud#3507, #3516, #3521 and #3578. This daemon never was, which is why the failures continued after those merged: across 71 busy failures in three days, not one carried a retry-exhaustion marker. The failing path had no retry at all. A 429 is now a yield, so the bootstrap resumes on the existing ticker inside its existing budget. No new retry machinery — the budget was always there, the cycle just had to stop declaring defeat at the first sign of it. Deliberately narrow: only 429. A 5xx is the server being broken rather than busy and must stay a real failure, or a genuinely unhealthy backend would look like a queue and spin the bootstrap for its whole budget instead of failing loudly. Tests cover both the 429 yield (including through error wrapping) and the 5xx/403/deadline cases that must NOT be treated as backpressure. Worth stating: this fixes the symptom. The workspace is busy because a handful of high-frequency callers hammer one per-workspace singleton, and reducing that call volume is the other half of the problem. go test ./cmd/relayfile-mount/ passes; go vet clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../backpressure_yield_test.go | 60 +++++++++++++++++++ cmd/relayfile-mount/main.go | 25 ++++++++ 2 files changed, 85 insertions(+) create mode 100644 cmd/relayfile-mount/backpressure_yield_test.go diff --git a/cmd/relayfile-mount/backpressure_yield_test.go b/cmd/relayfile-mount/backpressure_yield_test.go new file mode 100644 index 00000000..2a2b1891 --- /dev/null +++ b/cmd/relayfile-mount/backpressure_yield_test.go @@ -0,0 +1,60 @@ +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) + } + }) + } +} diff --git a/cmd/relayfile-mount/main.go b/cmd/relayfile-mount/main.go index f2e51f0f..d251fe99 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} + 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 @@ -1181,6 +1197,15 @@ 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 +} + func cycleYielded(err error) bool { var outcome *cycleOutcomeError return errors.As(err, &outcome) && outcome.yielded From 8f81c20b5bc55d044172adb7c56cafce53f5ef2f Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Fri, 18 Sep 2026 18:42:02 -0700 Subject: [PATCH 5/5] fix(mount): never report a cold backpressure yield as a completed bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin and Cursor both flagged this independently on #511, and they were right. Marking a 429 `yielded` was only half the change. `finishInitialBootstrap` treats a yielded first cycle with nothing in progress as a COMPLETED bootstrap, and that reasoning is sound for the only other thing that yields: a per-cycle deadline cannot reach that state without a persisted checkpoint, so "not in progress" genuinely means finished. A 429 breaks the assumption — it can arrive before the very first saveState, with nothing on disk and nothing synced. The previous commit therefore let a cold `--once` mount that met `workspace_busy` exit 0 and hand Cloud an EMPTY mirror it believed was fully bootstrapped. That is worse than the bug this PR set out to fix. The original failed loudly at 35s of a 210s budget; this would have failed silently, and the agent would have run against an empty tree. The yield now carries a `backpressure` marker distinguishing it from a deadline yield, and a cold backpressure yield returns the resumable incomplete outcome instead of falling through to success. That exits initialBootstrapIncompleteExitCode, so the caller reruns the mount — the correct middle ground between exit 1 (fatal, the original bug) and exit 0 (silently empty, the regression). Where a checkpoint DOES exist the behaviour is unchanged: the existing resume loop owns it. Tests: a 429 yield is distinguishable from a deadline yield in both directions, and the resumable outcome exits retryable under --once while a real failure stays fatal. go test ./cmd/relayfile-mount/ passes; go vet clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../backpressure_yield_test.go | 61 +++++++++++++++++++ cmd/relayfile-mount/main.go | 34 ++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/cmd/relayfile-mount/backpressure_yield_test.go b/cmd/relayfile-mount/backpressure_yield_test.go index 2a2b1891..9a1d1092 100644 --- a/cmd/relayfile-mount/backpressure_yield_test.go +++ b/cmd/relayfile-mount/backpressure_yield_test.go @@ -58,3 +58,64 @@ func TestOnlyTooManyRequestsCountsAsBackpressure(t *testing.T) { }) } } + +// 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 d251fe99..ea7a8545 100644 --- a/cmd/relayfile-mount/main.go +++ b/cmd/relayfile-mount/main.go @@ -709,7 +709,7 @@ func runSinglePollingMount(rootCtx context.Context, cfg mountConfig) error { // (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} + lastCycleErr = &cycleOutcomeError{cause: err, yielded: true, backpressure: true} log.Printf("mount sync cycle yielded to server backpressure (will retry): %v", err) return nil } @@ -1059,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) } @@ -1154,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 { @@ -1206,6 +1231,13 @@ func isBackpressureError(err error) bool { 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