From c445600374ab7e31b48726d5814b48ad3ba6be65 Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Thu, 17 Sep 2026 14:23:23 -0700 Subject: [PATCH 01/14] feat(cli-surface): expose @relayfile/sdk/relay-cli for agent-relay file Mount relayfile into the agent-relay CLI as `agent-relay file` without copying a single command implementation into relay. Go CLI: - New cmd/relayfile-cli/commandspec.go holds one declarative command table. run()'s 60-line top-level `switch` is replaced by a lookup into that table, so a top-level command cannot be declared without being routable, or routed without being declared. - Hidden `relayfile __command-spec --json` emits the table in the RelayCliCommandSpec[] shape @agent-relay/cli-surface defines. relayfile does not use cobra, so there is no command-object tree to walk; the table is the substitute, and commandspec_test.go parses each group's dispatch switch and each leaf's flag.FlagSet out of the source AST to assert the declared subcommands and options match the implementation exactly. - `writeback retry` gains `--op-id` as a kebab-case alias for `--opId`, which the contract's flag grammar cannot express. `--opId` still works. SDK: - New ./relay-cli subpath export. createRelayCliSurface() returns the surface (id 'relayfile', contract 1, 23 top-level commands); commands comes from a checked-in snapshot of the Go table, regenerated by `npm run gen:command-spec` and diffed by a test so it cannot drift; run(argv, io) spawns the same Go binary, pipes its stdout/stderr into io, forwards stdin, installs no signal handlers, calls no process.exit, and returns the child's real exit code. Unknown command exits 2. - Binary resolution and the Cloud sign-in preflight moved here from packages/cli/scripts, so each exists exactly once in the repo. - @agent-relay/cli-surface is a devDependency only: the surface is structurally typed, so the published package gains no runtime dep on relay. - relay-cli stays a subpath export, asserted by import-safety.test.ts, so the default entry never pulls node:child_process into memory. CLI package: - scripts/run.js and scripts/install.js now call the SDK instead of carrying their own copies of the platform map, binary lookup, and preflight. `relayfile --version` still short-circuits before any binary lookup. - cloud-preflight.js and its tests are replaced by cloud-auth.test.js (the vendored bundle) and run.test.js (the shim), with the preflight logic tests ported to the SDK. Verified: go test ./... and go vet ./... clean; SDK typecheck clean; 45 relay-cli tests pass against a real built binary and via `go run`; the surface imports and reports 23 commands from a packed tarball install. Co-Authored-By: Claude Opus 5 (1M context) Session-Id: 57ec71cd-46c6-41cf-8fb5-952f0cd36dab --- .trajectories/active/traj_jcnhoywu08ve.json | 46 + .trajectories/index.json | 10 +- cmd/relayfile-cli/commandspec.go | 1025 +++++++++++ cmd/relayfile-cli/commandspec_test.go | 469 +++++ cmd/relayfile-cli/main.go | 101 +- cmd/relayfile-cli/main_test.go | 4 +- package-lock.json | 31 +- packages/cli/CHANGELOG.md | 8 + packages/cli/package.json | 3 + packages/cli/scripts/cloud-auth.test.js | 170 ++ packages/cli/scripts/cloud-preflight.js | 312 ---- packages/cli/scripts/cloud-preflight.test.js | 491 ----- packages/cli/scripts/install.js | 78 +- packages/cli/scripts/run.js | 130 +- packages/cli/scripts/run.test.js | 61 + packages/sdk/typescript/CHANGELOG.md | 5 + packages/sdk/typescript/package.json | 11 +- .../scripts/copy-relay-cli-assets.mjs | 19 + .../typescript/scripts/gen-command-spec.mjs | 126 ++ .../sdk/typescript/src/import-safety.test.ts | 5 + .../src/relay-cli/cloud-preflight.test.ts | 315 ++++ .../src/relay-cli/cloud-preflight.ts | 429 +++++ .../src/relay-cli/command-spec.json | 1636 +++++++++++++++++ .../src/relay-cli/command-spec.test.ts | 80 + .../sdk/typescript/src/relay-cli/index.ts | 269 +++ .../src/relay-cli/resolve-binary.test.ts | 169 ++ .../src/relay-cli/resolve-binary.ts | 239 +++ .../typescript/src/relay-cli/surface.test.ts | 211 +++ .../src/relay-cli/testing/build-binary.ts | 74 + packages/sdk/typescript/tsconfig.json | 9 +- 30 files changed, 5523 insertions(+), 1013 deletions(-) create mode 100644 .trajectories/active/traj_jcnhoywu08ve.json create mode 100644 cmd/relayfile-cli/commandspec.go create mode 100644 cmd/relayfile-cli/commandspec_test.go create mode 100644 packages/cli/scripts/cloud-auth.test.js delete mode 100644 packages/cli/scripts/cloud-preflight.js delete mode 100644 packages/cli/scripts/cloud-preflight.test.js create mode 100644 packages/cli/scripts/run.test.js create mode 100644 packages/sdk/typescript/scripts/copy-relay-cli-assets.mjs create mode 100644 packages/sdk/typescript/scripts/gen-command-spec.mjs create mode 100644 packages/sdk/typescript/src/relay-cli/cloud-preflight.test.ts create mode 100644 packages/sdk/typescript/src/relay-cli/cloud-preflight.ts create mode 100644 packages/sdk/typescript/src/relay-cli/command-spec.json create mode 100644 packages/sdk/typescript/src/relay-cli/command-spec.test.ts create mode 100644 packages/sdk/typescript/src/relay-cli/index.ts create mode 100644 packages/sdk/typescript/src/relay-cli/resolve-binary.test.ts create mode 100644 packages/sdk/typescript/src/relay-cli/resolve-binary.ts create mode 100644 packages/sdk/typescript/src/relay-cli/surface.test.ts create mode 100644 packages/sdk/typescript/src/relay-cli/testing/build-binary.ts diff --git a/.trajectories/active/traj_jcnhoywu08ve.json b/.trajectories/active/traj_jcnhoywu08ve.json new file mode 100644 index 00000000..c89a9c70 --- /dev/null +++ b/.trajectories/active/traj_jcnhoywu08ve.json @@ -0,0 +1,46 @@ +{ + "id": "traj_jcnhoywu08ve", + "version": 1, + "task": { + "title": "Expose @relayfile/sdk/relay-cli CLI surface for agent-relay file" + }, + "status": "active", + "startedAt": "2026-09-17T21:00:11.799Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-09-17T21:23:08.808Z" + } + ], + "chapters": [ + { + "id": "chap_yz8wjtt5nove", + "title": "Work", + "agentName": "default", + "startedAt": "2026-09-17T21:23:08.808Z", + "events": [ + { + "ts": 1789680188809, + "type": "decision", + "content": "Declarative Go command table drives run() dispatch; AST drift tests for nested levels: Declarative Go command table drives run() dispatch; AST drift tests for nested levels", + "raw": { + "question": "Declarative Go command table drives run() dispatch; AST drift tests for nested levels", + "chosen": "Declarative Go command table drives run() dispatch; AST drift tests for nested levels", + "alternatives": [], + "reasoning": "relayfile's Go CLI is a hand-rolled flag dispatcher, not cobra, so there is no command tree to walk. Making the table the dispatcher removes top-level drift structurally; parsing the per-group switches and each leaf FlagSet out of the source AST catches nested drift without rewriting 14.7k lines." + }, + "significance": "high" + } + ] + } + ], + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relayfile", + "tags": [], + "_trace": { + "startRef": "672260b97dc48963b6125a37f6531a28abbc9a90", + "endRef": "672260b97dc48963b6125a37f6531a28abbc9a90" + } +} \ No newline at end of file diff --git a/.trajectories/index.json b/.trajectories/index.json index 62c092ec..f242c8db 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-09-09T22:38:01.679Z", + "lastUpdated": "2026-09-17T21:23:08.811Z", "trajectories": { "traj_4pvrlmqfnzng": { "title": "Review PR #278 in AgentWorkforce/relayfile", @@ -372,6 +372,12 @@ "startedAt": "2026-09-09T22:35:53.204Z", "completedAt": "2026-09-09T22:38:01.543Z", "path": ".trajectories/completed/2026-09/traj_xq9s9vigbhum.json" + }, + "traj_jcnhoywu08ve": { + "title": "Expose @relayfile/sdk/relay-cli CLI surface for agent-relay file", + "status": "active", + "startedAt": "2026-09-17T21:00:11.799Z", + "path": "/home/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/active/traj_jcnhoywu08ve.json" } } -} +} \ No newline at end of file diff --git a/cmd/relayfile-cli/commandspec.go b/cmd/relayfile-cli/commandspec.go new file mode 100644 index 00000000..469c3fcd --- /dev/null +++ b/cmd/relayfile-cli/commandspec.go @@ -0,0 +1,1025 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "strings" +) + +// The command table below is the single source of truth for relayfile's CLI +// surface. run() dispatches top-level argv through it, so a top-level command +// cannot exist in the table without being routable or be routable without +// being in the table. +// +// `relayfile __command-spec --json` emits the same table as the +// RelayCliCommandSpec[] shape defined by @agent-relay/cli-surface, which +// packages/sdk/typescript checks in as a snapshot so `agent-relay file --help` +// works without the binary present. Regenerate with +// `npm run gen:command-spec --workspace=packages/sdk/typescript`. +// +// Nested levels still dispatch inside their group's own switch (runWorkspace, +// runIntegration, ...). commandspec_test.go parses those switches and each +// leaf's flag.FlagSet out of the source AST and asserts they match what this +// table declares, so the declared tree cannot silently drift from the +// implementation. + +// cliArgSpec mirrors RelayCliArgSpec. +type cliArgSpec struct { + Name string `json:"name"` + Description string `json:"description"` + Required bool `json:"required"` + Variadic bool `json:"variadic,omitempty"` +} + +// cliOptionSpec mirrors RelayCliOptionSpec. Flags is a commander-style flag +// string; the contract's conformance check requires lowercase kebab-case long +// flags, so a Go flag that is not kebab-case must ship a kebab-case alias and +// declare that alias here. +// +// DefaultValue is only populated for defaults that are constant everywhere. +// Defaults derived from the environment or the host filesystem (server URLs, +// state directories, socket paths) are deliberately omitted: this table is +// serialized into a checked-in snapshot, and machine-specific values there +// would both leak local paths and make the snapshot unreproducible. +type cliOptionSpec struct { + Flags string `json:"flags"` + Description string `json:"description"` + DefaultValue any `json:"defaultValue,omitempty"` +} + +// cliCommandSpec mirrors RelayCliCommandSpec, plus the unexported bookkeeping +// the dispatcher and the drift test need. +type cliCommandSpec struct { + Name string `json:"name"` + Description string `json:"description"` + Aliases []string `json:"aliases,omitempty"` + Args []cliArgSpec `json:"args,omitempty"` + Options []cliOptionSpec `json:"options,omitempty"` + Subcommands []cliCommandSpec `json:"subcommands,omitempty"` + Hidden bool `json:"hidden,omitempty"` + + // dispatch routes a top-level invocation. Only top-level entries set it. + dispatch func(cliInvocation) error + + // flagSource names the Go function that owns this command's + // flag.FlagSet. The drift test asserts Options matches the flags that + // function registers. Empty means the command registers no flags of its + // own (it forwards argv, or takes none). + flagSource string + + // dispatchSource names the Go function whose switch routes this + // command's subcommands. The drift test asserts Subcommands matches that + // switch's cases exactly. + dispatchSource string + + // internal keeps a command out of the emitted spec. Reserved for + // introspection hooks that the host provides itself or that are not part + // of the product surface. + internal bool +} + +// cliInvocation carries everything a dispatched command needs. It exists so +// the table can hold one uniform dispatch signature even though the underlying +// run* functions take different subsets. +type cliInvocation struct { + args []string + stdin io.Reader + stdout io.Writer + stderr io.Writer +} + +const commandSpecCommandName = "__command-spec" + +var workspaceFlagOption = cliOptionSpec{ + Flags: "--workspace ", + Description: "workspace name or id", +} + +var serverFlagOption = cliOptionSpec{ + Flags: "--server ", + Description: "relayfile server URL override", +} + +var tokenFlagOption = cliOptionSpec{ + Flags: "--token ", + Description: "relayfile token override", +} + +var jsonFlagOption = cliOptionSpec{ + Flags: "--json", + Description: "emit JSON", + DefaultValue: false, +} + +var cloudAPIURLOption = cliOptionSpec{ + Flags: "--cloud-api-url ", + Description: "Relayfile Cloud API URL (default: $RELAYFILE_CLOUD_API_URL or https://agentrelay.com/cloud)", +} + +var workspaceArg = cliArgSpec{ + Name: "workspace", + Description: "workspace name or id; defaults to the active workspace", + Required: false, +} + +// relayfileCommands returns the declared command tree. +func relayfileCommands() []cliCommandSpec { + return []cliCommandSpec{ + { + Name: "setup", + Description: "Sign in, connect an integration, and mount the workspace", + flagSource: "runSetupWithOptions", + Options: []cliOptionSpec{ + cloudAPIURLOption, + {Flags: "--cloud-token ", Description: "Relayfile Cloud access token; skips browser login when set"}, + {Flags: "--workspace ", Description: "workspace name to create"}, + {Flags: "--provider ", Description: "integration provider to connect; use none to skip"}, + {Flags: "--backend ", Description: "integration backend to request (nango or composio)"}, + {Flags: "--local-dir ", Description: "local mount directory"}, + {Flags: "--no-open", Description: "print browser URLs instead of opening them", DefaultValue: false}, + {Flags: "--skip-mount", Description: "finish after setup without starting the mount process", DefaultValue: false}, + {Flags: "--once", Description: "run one mount sync cycle and exit", DefaultValue: false}, + {Flags: "--login-timeout ", Description: "cloud login timeout", DefaultValue: "5m0s"}, + {Flags: "--connect-timeout ", Description: "integration connection timeout", DefaultValue: "5m0s"}, + }, + dispatch: func(inv cliInvocation) error { + return runSetup(inv.args, inv.stdin, inv.stdout) + }, + }, + { + Name: "login", + Description: "Sign in via agent-relay cloud login (or --api-key for self-hosted)", + flagSource: "runLogin", + Options: []cliOptionSpec{ + {Flags: "--server ", Description: "relayfile server URL (only used with --api-key)"}, + {Flags: "--token ", Description: "relayfile API token"}, + cloudAPIURLOption, + {Flags: "--cloud-token ", Description: "Relayfile Cloud access token; skips browser login when set"}, + {Flags: "--api-key", Description: "use the legacy API-key flow against --server instead of the cloud browser login", DefaultValue: false}, + {Flags: "--no-open", Description: "print the cloud sign-in URL instead of opening it", DefaultValue: false}, + {Flags: "--login-timeout ", Description: "cloud login timeout", DefaultValue: "5m0s"}, + {Flags: "--workspace ", Description: "workspace name or id to refresh; defaults to the active workspace"}, + {Flags: "--skip-workspace-refresh", Description: "sign into the cloud only; do not refresh the workspace token", DefaultValue: false}, + {Flags: "--provision-messaging-only", Description: "create a separate Relayfile-backed workspace when the active Agent Relay workspace is messaging-only", DefaultValue: false}, + }, + dispatch: func(inv cliInvocation) error { + return runLogin(inv.args, inv.stdin, inv.stdout) + }, + }, + { + Name: "logout", + Description: "Clear Relayfile credentials from this machine", + dispatch: func(inv cliInvocation) error { + return runLogout(inv.args, inv.stdout) + }, + }, + { + Name: "workspace", + Description: "Create, join, select via agent-relay, list, show current, or delete locally tracked workspaces", + dispatchSource: "runWorkspace", + Subcommands: []cliCommandSpec{ + { + Name: "create", + Description: "Create a workspace on the relayfile server", + flagSource: "runWorkspaceCreate", + Args: []cliArgSpec{{Name: "name", Description: "workspace name", Required: true}}, + Options: []cliOptionSpec{tokenFlagOption}, + }, + { + Name: "join", + Description: "Join an existing workspace by id and track it locally", + flagSource: "runWorkspaceJoin", + Args: []cliArgSpec{{Name: "workspace-id", Description: "workspace id to join", Required: true}}, + Options: []cliOptionSpec{ + cloudAPIURLOption, + {Flags: "--cloud-token ", Description: "Relayfile Cloud access token; skips browser login when set"}, + {Flags: "--name ", Description: "local workspace name"}, + {Flags: "--write", Description: "request read/write workspace token scopes", DefaultValue: false}, + {Flags: "--no-open", Description: "print browser URLs instead of opening them", DefaultValue: false}, + {Flags: "--login-timeout ", Description: "cloud login timeout", DefaultValue: "5m0s"}, + }, + }, + { + Name: "use", + Description: "Select the active workspace for later commands", + flagSource: "runWorkspaceUse", + Args: []cliArgSpec{{Name: "name", Description: "workspace name or id", Required: true}}, + }, + { + Name: "list", + Description: "List locally tracked workspaces", + flagSource: "runWorkspaceList", + Options: []cliOptionSpec{ + serverFlagOption, + tokenFlagOption, + {Flags: "--names-only", Description: "print bare workspace names without an active marker", DefaultValue: false}, + }, + }, + { + Name: "current", + Description: "Show the active workspace", + flagSource: "runWorkspaceCurrent", + Options: []cliOptionSpec{ + tokenFlagOption, + {Flags: "--verbose", Description: "include workspace id and selection source", DefaultValue: false}, + }, + }, + { + Name: "view", + Description: "Manage read-only aliases into a registered workspace mirror", + dispatchSource: "runWorkspaceView", + Subcommands: []cliCommandSpec{ + { + Name: "add", + Description: "Create an alias directory pointing into the canonical mirror", + flagSource: "runWorkspaceViewAdd", + Args: []cliArgSpec{ + {Name: "remote-path", Description: "remote path to expose", Required: true}, + {Name: "local-dir", Description: "local alias directory", Required: true}, + }, + Options: []cliOptionSpec{ + workspaceFlagOption, + {Flags: "--replace", Description: "replace an existing relayfile view symlink", DefaultValue: false}, + }, + }, + { + Name: "list", + Description: "List alias directories registered for a workspace", + flagSource: "runWorkspaceViewList", + Options: []cliOptionSpec{workspaceFlagOption, jsonFlagOption}, + }, + { + Name: "remove", + Description: "Remove an alias directory", + flagSource: "runWorkspaceViewRemove", + Args: []cliArgSpec{{Name: "local-dir", Description: "local alias directory", Required: true}}, + Options: []cliOptionSpec{workspaceFlagOption}, + }, + }, + }, + { + Name: "status", + Description: "Show sync status for a workspace", + flagSource: "runWorkspaceStatus", + Options: []cliOptionSpec{workspaceFlagOption, jsonFlagOption}, + }, + { + Name: "delete", + Description: "Delete a locally tracked workspace", + flagSource: "runWorkspaceDelete", + Args: []cliArgSpec{{Name: "name", Description: "workspace name or id", Required: true}}, + Options: []cliOptionSpec{ + {Flags: "--yes", Description: "skip confirmation prompt", DefaultValue: false}, + }, + }, + }, + dispatch: func(inv cliInvocation) error { + return runWorkspace(inv.args, inv.stdin, inv.stdout) + }, + }, + { + Name: "integration", + Description: "Connect, discover, list, disconnect, or adopt workspace integrations", + dispatchSource: "runIntegration", + Subcommands: []cliCommandSpec{ + { + Name: "connect", + Description: "Connect a provider integration to a workspace", + flagSource: "runIntegrationConnect", + Args: []cliArgSpec{{Name: "provider", Description: "provider id, e.g. github or linear", Required: true}}, + Options: []cliOptionSpec{ + workspaceFlagOption, + cloudAPIURLOption, + {Flags: "--backend ", Description: "integration backend to request (nango or composio)"}, + {Flags: "--no-open", Description: "print the hosted URL instead of opening it", DefaultValue: false}, + {Flags: "--timeout ", Description: "integration readiness timeout", DefaultValue: "5m0s"}, + {Flags: "--wait-sync", Description: "wait for initial sync before returning", DefaultValue: false}, + }, + }, + { + Name: "available", + Description: "List providers available to connect", + Aliases: []string{"catalog", "providers"}, + flagSource: "runIntegrationAvailable", + Options: []cliOptionSpec{ + cloudAPIURLOption, + {Flags: "--backend ", Description: "filter by backend (nango or composio)"}, + {Flags: "--search ", Description: "search provider id, display name, category, or backend"}, + jsonFlagOption, + {Flags: "--refresh", Description: "refresh the cached provider catalog", DefaultValue: false}, + }, + }, + { + Name: "search", + Description: "Search the provider catalog", + flagSource: "runIntegrationSearch", + Args: []cliArgSpec{{Name: "query", Description: "search query", Required: true}}, + Options: []cliOptionSpec{ + cloudAPIURLOption, + {Flags: "--backend ", Description: "filter by backend (nango or composio)"}, + jsonFlagOption, + {Flags: "--refresh", Description: "refresh the cached provider catalog", DefaultValue: false}, + }, + }, + { + Name: "list", + Description: "List a workspace's connected integrations", + flagSource: "runIntegrationList", + Options: []cliOptionSpec{ + workspaceFlagOption, + jsonFlagOption, + cloudAPIURLOption, + {Flags: "--cloud-token ", Description: "Relayfile Cloud access token"}, + }, + }, + { + Name: "disconnect", + Description: "Disconnect a provider integration", + flagSource: "runIntegrationDisconnect", + Args: []cliArgSpec{{Name: "provider", Description: "provider id", Required: true}}, + Options: []cliOptionSpec{ + workspaceFlagOption, + cloudAPIURLOption, + {Flags: "--yes", Description: "skip confirmation", DefaultValue: false}, + }, + }, + { + Name: "adopt", + Description: "Adopt an existing Nango connection as a workspace integration", + flagSource: "runIntegrationAdopt", + Args: []cliArgSpec{{Name: "provider", Description: "provider id", Required: true}}, + Options: []cliOptionSpec{ + workspaceFlagOption, + cloudAPIURLOption, + {Flags: "--connection-id ", Description: "Nango connection id to adopt (required)"}, + {Flags: "--provider-config-key ", Description: "optional Nango providerConfigKey override"}, + {Flags: "--yes", Description: "skip confirmation", DefaultValue: false}, + }, + }, + { + Name: "set-metadata", + Description: "Set provider connection metadata as KEY=VALUE pairs", + flagSource: "runIntegrationSetMetadata", + Args: []cliArgSpec{ + {Name: "provider", Description: "provider id", Required: true}, + {Name: "assignments", Description: "one or more KEY=VALUE metadata assignments", Required: true, Variadic: true}, + }, + Options: []cliOptionSpec{ + workspaceFlagOption, + cloudAPIURLOption, + {Flags: "--yes", Description: "skip confirmation", DefaultValue: false}, + }, + }, + { + Name: "bind", + Description: "Bind a provider resource or path glob to a relay channel", + flagSource: "runIntegrationBind", + Args: []cliArgSpec{ + {Name: "provider", Description: "provider id", Required: false}, + {Name: "resource", Description: "provider resource or path glob", Required: false}, + }, + Options: []cliOptionSpec{ + {Flags: "--list", Description: "list active relay bindings as JSON", DefaultValue: false}, + {Flags: "--json", Description: "accepted for consistency with other JSON-emitting integration commands", DefaultValue: false}, + {Flags: "--channel ", Description: "relay channel to receive provider records"}, + {Flags: "--webhook ", Description: "RelayCast inbound webhook id"}, + {Flags: "--webhook-token ", Description: "RelayCast inbound webhook token"}, + {Flags: "--subscription ", Description: "relay integration subscription id"}, + {Flags: "--webhook-subscription ", Description: "relayfile-cloud inbound webhook subscription id"}, + {Flags: "--webhook-subscription-workspace ", Description: "workspace the webhook subscription was created in (pairs with --webhook-subscription)"}, + }, + }, + { + Name: "resolve-path", + Description: "Resolve a provider resource to its relayfile path", + flagSource: "runIntegrationResolvePath", + Args: []cliArgSpec{ + {Name: "provider", Description: "provider id", Required: true}, + {Name: "resource", Description: "provider resource identifier", Required: true}, + }, + Options: []cliOptionSpec{jsonFlagOption}, + }, + { + Name: "unbind", + Description: "Remove a relay binding for a provider", + flagSource: "runIntegrationUnbind", + Args: []cliArgSpec{ + {Name: "provider", Description: "provider id", Required: true}, + {Name: "resource", Description: "path glob or resource to unbind; may be passed as --resource instead", Required: false}, + }, + Options: []cliOptionSpec{ + {Flags: "--resource ", Description: "path glob/resource to unbind"}, + }, + }, + { + Name: "writeback-secret", + Description: "Print the writeback secret for a bound relay channel", + flagSource: "runIntegrationWritebackSecret", + Options: []cliOptionSpec{ + workspaceFlagOption, + {Flags: "--channel ", Description: "relay channel the binding delivers to"}, + jsonFlagOption, + }, + }, + }, + dispatch: func(inv cliInvocation) error { + return runIntegration(inv.args, inv.stdin, inv.stdout) + }, + }, + { + Name: "ops", + Description: "List or replay dead-lettered writeback ops", + dispatchSource: "runOps", + Subcommands: []cliCommandSpec{ + { + Name: "list", + Description: "List dead-lettered writeback ops", + flagSource: "runOpsList", + Options: []cliOptionSpec{ + workspaceFlagOption, + jsonFlagOption, + {Flags: "--no-refresh", Description: "skip refreshing the local mirror from the server", DefaultValue: false}, + serverFlagOption, + tokenFlagOption, + }, + }, + { + Name: "replay", + Description: "Replay one dead-lettered writeback op", + flagSource: "runOpsReplay", + Args: []cliArgSpec{{Name: "op-id", Description: "dead-lettered operation id", Required: true}}, + Options: []cliOptionSpec{workspaceFlagOption, cloudAPIURLOption}, + }, + }, + dispatch: func(inv cliInvocation) error { + return runOps(inv.args, inv.stdin, inv.stdout) + }, + }, + { + Name: "writeback", + Description: "Inspect or retry local writeback failures", + dispatchSource: "runWriteback", + Subcommands: []cliCommandSpec{ + { + Name: "list", + Description: "List local writeback items by state", + flagSource: "runWritebackList", + Options: []cliOptionSpec{ + {Flags: "--state ", Description: "writeback state: pending or dead"}, + workspaceFlagOption, + jsonFlagOption, + }, + }, + { + Name: "push", + Description: "Push a local file to the workspace and wait for its receipt", + flagSource: "runWritebackFileMutation", + Args: []cliArgSpec{{Name: "local-path", Description: "local mirror path to push", Required: true}}, + Options: writebackMutationOptions(), + }, + { + Name: "update", + Description: "Update a workspace file from its local mirror copy", + flagSource: "runWritebackFileMutation", + Args: []cliArgSpec{{Name: "local-path", Description: "local mirror path to update", Required: true}}, + Options: writebackMutationOptions(), + }, + { + Name: "delete", + Description: "Delete a workspace file via its local mirror path", + flagSource: "runWritebackFileMutation", + Args: []cliArgSpec{{Name: "local-path", Description: "local mirror path to delete", Required: true}}, + Options: writebackMutationOptions(), + }, + { + Name: "status", + Description: "Show local pending, failed, and dead-lettered writebacks", + flagSource: "runWritebackStatus", + Args: []cliArgSpec{workspaceArg}, + Options: []cliOptionSpec{jsonFlagOption}, + }, + { + Name: "retry", + Description: "Re-enqueue a local dead-lettered writeback op", + flagSource: "runWritebackRetry", + Args: []cliArgSpec{workspaceArg}, + Options: []cliOptionSpec{ + {Flags: "--op-id ", Description: "dead-lettered operation id (also accepted as --opId)"}, + }, + }, + { + Name: "skip-stuck", + Description: "Walk the events cursor past stuck (404) events without waiting the treat-as-deleted timer", + flagSource: "runWritebackSkipStuck", + Args: []cliArgSpec{workspaceArg}, + Options: []cliOptionSpec{ + workspaceFlagOption, + {Flags: "--max ", Description: "maximum number of stuck events to skip (0 = unbounded)", DefaultValue: 0}, + jsonFlagOption, + }, + }, + { + Name: "sweep-drafts", + Description: "Remove hand-named draft files left behind under a workspace subtree", + flagSource: "runWritebackSweepDrafts", + Args: []cliArgSpec{workspaceArg}, + Options: []cliOptionSpec{ + {Flags: "--path-prefix ", Description: "restrict the sweep to a subtree"}, + {Flags: "--pattern ", Description: "basename glob for hand-named drafts (repeatable), e.g. wb-*.json"}, + {Flags: "--apply", Description: "execute removals (default is a dry run)", DefaultValue: false}, + jsonFlagOption, + serverFlagOption, + tokenFlagOption, + }, + }, + }, + dispatch: func(inv cliInvocation) error { + return runWriteback(inv.args, inv.stdout) + }, + }, + { + Name: "digest", + Description: "Regenerate workspace digests", + dispatchSource: "runDigest", + Subcommands: []cliCommandSpec{ + { + Name: "rebuild", + Description: "Regenerate daily, weekly, or date-stamped digest artifacts", + flagSource: "runDigestRebuild", + Options: []cliOptionSpec{ + {Flags: "--window ", Description: "digest window: today, yesterday, this-week, last-week, or YYYY-MM-DD"}, + workspaceFlagOption, + {Flags: "--json", Description: "print machine-readable JSON", DefaultValue: false}, + }, + }, + }, + dispatch: func(inv cliInvocation) error { + return runDigest(inv.args, inv.stdout) + }, + }, + { + Name: "pull", + Description: "Trigger an immediate sync refresh for one or all providers", + flagSource: "runPull", + Options: []cliOptionSpec{ + workspaceFlagOption, + {Flags: "--provider ", Description: "provider id (default: refresh all connected providers)"}, + {Flags: "--reason ", Description: "free-form reason recorded server-side", DefaultValue: "manual"}, + serverFlagOption, + tokenFlagOption, + }, + dispatch: func(inv cliInvocation) error { + return runPull(inv.args, inv.stdout) + }, + }, + { + Name: "mount", + Description: "Mirror a remote workspace to a local directory; add --background to detach", + Aliases: []string{"start", "on"}, + flagSource: "runMount", + Args: []cliArgSpec{ + workspaceArg, + {Name: "local-dir", Description: "local mirror directory", Required: false}, + }, + Options: mountOptions(), + Subcommands: mountSealCommands(), + dispatch: func(inv cliInvocation) error { + // `start` and `on` are friendlier aliases for `mount`. Same + // flags, same foreground/background behavior; pass + // --background to detach. + if len(inv.args) > 0 { + if seal, ok := mountSealDispatch[inv.args[0]]; ok { + return seal(cliInvocation{ + args: inv.args[1:], + stdin: inv.stdin, + stdout: inv.stdout, + stderr: inv.stderr, + }) + } + } + return runMount(inv.args) + }, + }, + { + Name: "restart", + Description: "Stop and start a workspace's mount in one step (--foreground to attach)", + flagSource: "runRestart", + Args: []cliArgSpec{workspaceArg}, + Options: []cliOptionSpec{ + {Flags: "--foreground", Description: "run the restarted mount in the foreground instead of detaching", DefaultValue: false}, + }, + dispatch: func(inv cliInvocation) error { + return runRestart(inv.args, inv.stdout) + }, + }, + { + Name: "supervisor", + Description: "Install/uninstall/status launchd (macOS) or systemd (Linux) service for auto-restart", + dispatchSource: "runSupervisor", + Subcommands: []cliCommandSpec{ + { + Name: "install", + Description: "Install the auto-restart service for a workspace mount", + Args: []cliArgSpec{workspaceArg}, + Options: []cliOptionSpec{ + // supervisor install forwards its argv to + // `relayfile listen`, which owns these flags. + {Flags: "--interval ", Description: "sync interval passed through to the supervised listen process"}, + }, + }, + { + Name: "uninstall", + Description: "Remove the auto-restart service", + Aliases: []string{"remove"}, + }, + { + Name: "status", + Description: "Show the auto-restart service state", + }, + }, + dispatch: func(inv cliInvocation) error { + return runSupervisor(inv.args, inv.stdout) + }, + }, + { + Name: "tree", + Description: "List a remote workspace path", + Aliases: []string{"ls"}, + flagSource: "runTree", + Args: []cliArgSpec{ + workspaceArg, + {Name: "path", Description: "remote path to list; defaults to /", Required: false}, + }, + Options: []cliOptionSpec{ + serverFlagOption, + tokenFlagOption, + {Flags: "--path ", Description: "remote path to list", DefaultValue: "/"}, + {Flags: "--depth ", Description: "tree depth", DefaultValue: 1}, + {Flags: "--json", Description: "print the raw JSON response", DefaultValue: false}, + }, + dispatch: func(inv cliInvocation) error { + return runTree(inv.args, inv.stdout) + }, + }, + { + Name: "read", + Description: "Print a remote file's content", + Aliases: []string{"cat"}, + flagSource: "runRead", + Args: []cliArgSpec{ + workspaceArg, + // PATH is required, but the contract forbids a required + // positional after an optional one and relayfile accepts + // either `read PATH` or `read WORKSPACE PATH`. Both are + // declared optional; the command rejects an empty path. + {Name: "path", Description: "remote file path (required; the sole positional when no workspace is given)", Required: false}, + }, + Options: []cliOptionSpec{ + serverFlagOption, + tokenFlagOption, + {Flags: "--output ", Description: "output file path or - for stdout", DefaultValue: "-"}, + {Flags: "--json", Description: "print the raw JSON response", DefaultValue: false}, + }, + dispatch: func(inv cliInvocation) error { + return runRead(inv.args, inv.stdout) + }, + }, + { + Name: "seed", + Description: "Upload a directory tree with bulk writes", + flagSource: "runSeed", + Args: []cliArgSpec{ + workspaceArg, + {Name: "dir", Description: "local directory to upload; defaults to the current directory", Required: false}, + }, + Options: []cliOptionSpec{serverFlagOption, tokenFlagOption}, + dispatch: func(inv cliInvocation) error { + return runSeed(inv.args, inv.stdout) + }, + }, + { + Name: "export", + Description: "Export a workspace as json, tar, or patch", + flagSource: "runExport", + Args: []cliArgSpec{workspaceArg}, + Options: []cliOptionSpec{ + serverFlagOption, + tokenFlagOption, + {Flags: "--format ", Description: "export format: tar, json, or patch", DefaultValue: "json"}, + {Flags: "--output ", Description: "output file path or - for stdout", DefaultValue: "-"}, + }, + dispatch: func(inv cliInvocation) error { + return runExport(inv.args, inv.stdout) + }, + }, + { + Name: "status", + Description: "Show sync status and local mirror state for a workspace", + flagSource: "runStatus", + Args: []cliArgSpec{workspaceArg}, + Options: []cliOptionSpec{serverFlagOption, tokenFlagOption, jsonFlagOption}, + dispatch: func(inv cliInvocation) error { + return runStatus(inv.args, inv.stdout) + }, + }, + { + Name: "stop", + Description: "Stop a background mount", + Aliases: []string{"off"}, + flagSource: "runStop", + Args: []cliArgSpec{workspaceArg}, + dispatch: func(inv cliInvocation) error { + // `off` is the friendlier alias for `stop`, migrating the + // agent-relay `relay off` unmount UX into relayfile. + return runStop(inv.args, inv.stdout) + }, + }, + { + Name: "logs", + Description: "Print the background mount log", + flagSource: "runLogs", + Args: []cliArgSpec{workspaceArg}, + Options: []cliOptionSpec{ + {Flags: "--lines ", Description: "number of lines to print", DefaultValue: 40}, + }, + dispatch: func(inv cliInvocation) error { + return runLogs(inv.args, inv.stdout) + }, + }, + { + Name: "observer", + Description: "Open the hosted file observer for a workspace", + flagSource: "runObserver", + Args: []cliArgSpec{workspaceArg}, + Options: []cliOptionSpec{ + serverFlagOption, + tokenFlagOption, + {Flags: "--url ", Description: "observer URL (default: $RELAYFILE_OBSERVER_URL or the hosted observer)"}, + {Flags: "--no-open", Description: "print the observer URL without opening a browser", DefaultValue: false}, + }, + dispatch: func(inv cliInvocation) error { + return runObserver(inv.args, inv.stdout) + }, + }, + { + Name: "listen", + Description: "Stream workspace file events, optionally running a command per event", + Aliases: []string{"watch"}, + flagSource: "runListen", + Options: listenOptions(), + dispatch: func(inv cliInvocation) error { + return runListen(inv.args, inv.stdout) + }, + }, + { + Name: "control-plane", + Description: "Serve the local relayfile control-plane socket", + dispatchSource: "runControlPlane", + Subcommands: []cliCommandSpec{ + { + Name: "serve", + Description: "Serve the control-plane unix socket", + flagSource: "runControlPlaneServe", + Options: []cliOptionSpec{ + {Flags: "--sock ", Description: "unix socket path (default: the per-user relayfile socket)"}, + }, + }, + }, + dispatch: func(inv cliInvocation) error { + return runControlPlane(inv.args, inv.stdout) + }, + }, + { + Name: "dev", + Description: "Print workspace context, then stream file events like `listen`", + Hidden: true, + // dev forwards its argv verbatim to runListen, so it accepts + // exactly listen's flags. + flagSource: "runListen", + Options: listenOptions(), + dispatch: func(inv cliInvocation) error { + return runDev(inv.args, inv.stdin, inv.stdout) + }, + }, + { + // The host CLI renders help from the emitted spec, so relayfile's + // own `help` stays routable but out of the product surface. + Name: "help", + Description: "Print relayfile usage", + internal: true, + dispatch: func(inv cliInvocation) error { + printUsage(inv.stdout) + return nil + }, + }, + { + // Introspection hook: emits this table so @relayfile/sdk can + // snapshot it. Excluded from the emitted spec both because it is + // not a product command and because its name is not the + // kebab-case the contract requires. + Name: commandSpecCommandName, + Description: "Emit the relayfile command tree as RelayCliCommandSpec JSON", + internal: true, + dispatch: func(inv cliInvocation) error { + return runCommandSpec(inv.args, inv.stdout) + }, + }, + } +} + +func writebackMutationOptions() []cliOptionSpec { + return []cliOptionSpec{ + workspaceFlagOption, + serverFlagOption, + tokenFlagOption, + jsonFlagOption, + {Flags: "--timeout ", Description: "operation receipt wait timeout", DefaultValue: "1m30s"}, + } +} + +func listenOptions() []cliOptionSpec { + return []cliOptionSpec{ + serverFlagOption, + tokenFlagOption, + {Flags: "--provider ", Description: "filter to a specific provider (e.g. linear, notion)"}, + {Flags: "--path ", Description: "glob path filter (e.g. /linear/issues/**)"}, + {Flags: "--event ", Description: "event type filter: file.created, file.updated, file.deleted"}, + {Flags: "--run ", Description: "shell command per event; supports {{path}}, {{type}}, {{provider}}, {{revision}}, {{event}}"}, + {Flags: "--format ", Description: "output format when --run is not set: text or json", DefaultValue: "text"}, + {Flags: "--background", Description: "run in background; logs to ~/.relayfile/listen.log", DefaultValue: false}, + {Flags: "--daemonized", Description: "internal flag used by relayfile listen --background", DefaultValue: false}, + } +} + +func mountOptions() []cliOptionSpec { + return []cliOptionSpec{ + {Flags: "--server ", Description: "relayfile server URL"}, + {Flags: "--token ", Description: "bearer token"}, + {Flags: "--creds-file ", Description: "delegated relayfile credentials file"}, + {Flags: "--remote-path ", Description: "remote root path (may be repeated)"}, + {Flags: "--paths-file ", Description: "file containing remote root paths, as a JSON array or newline-separated list"}, + {Flags: "--local-layout ", Description: "local directory layout: exact or scoped"}, + {Flags: "--provider ", Description: "event provider filter"}, + {Flags: "--state-file ", Description: "state file path"}, + {Flags: "--state-dir ", Description: "directory for private mount state"}, + {Flags: "--mount-kind ", Description: "private state identity kind: daemon, flush, or initial-sync"}, + {Flags: "--local-dir ", Description: "local mirror directory"}, + {Flags: "--mode ", Description: "mount mode: poll (recommended) or fuse"}, + {Flags: "--interval ", Description: "sync interval"}, + {Flags: "--interval-jitter ", Description: "sync interval jitter ratio (0.0-1.0)"}, + {Flags: "--timeout ", Description: "per-sync timeout"}, + {Flags: "--bootstrap-timeout ", Description: "hard cap for the one-time/full-tree bootstrap pull (0 = unbounded while making progress)"}, + {Flags: "--bootstrap-max-files-per-cycle ", Description: "maximum files materialized per resumable tree-bootstrap cycle (-1 = legacy unbounded tree behavior)"}, + {Flags: "--full-pull-min-interval ", Description: "minimum wall-clock interval between completed periodic full-tree audits (-1 disables the time guard)"}, + {Flags: "--cursor-timeout ", Description: "independent timeout for events-cursor resolution"}, + {Flags: "--full-reconcile", Description: "force one full reconcile regardless of bootstrap-complete state (escape hatch)"}, + {Flags: "--websocket", Description: "enable websocket event streaming when available"}, + {Flags: "--low-memory", Description: "reduce mount memory use by omitting per-file public state and deferring content reads"}, + {Flags: "--pprof-addr ", Description: "optional pprof listen address, e.g. 127.0.0.1:6060"}, + {Flags: "--memlog-interval ", Description: "optional interval for logging runtime memory stats"}, + {Flags: "--background", Description: "detach and keep syncing in the background", DefaultValue: false}, + {Flags: "--pid-file ", Description: "pid file path for background mode"}, + {Flags: "--log-file ", Description: "log file path for background mode"}, + {Flags: "--daemonized", Description: "internal flag used by relayfile mount --background", DefaultValue: false}, + {Flags: "--once", Description: "run one sync cycle and exit", DefaultValue: false}, + {Flags: "--reset-after-clobber", Description: "acknowledge a mount-root clobber and authorize daemon to recreate the directory"}, + {Flags: "--rehome", Description: "allow re-homing an already-registered workspace mirror to a different LOCAL_DIR", DefaultValue: false}, + } +} + +func mountSealCommands() []cliCommandSpec { + sealOptions := func(timeoutDescription string) []cliOptionSpec { + return []cliOptionSpec{ + {Flags: "--root ", Description: "absolute local mount root"}, + {Flags: "--timeout ", Description: timeoutDescription}, + {Flags: "--json", Description: "emit the machine contract", DefaultValue: false}, + } + } + return []cliCommandSpec{ + { + Name: "checkpoint-seal", + Description: "Seal a mount checkpoint for controller-driven cutover", + Hidden: true, + flagSource: "runMountCheckpointSeal", + Options: []cliOptionSpec{ + {Flags: "--root ", Description: "absolute local mount root"}, + {Flags: "--lifecycle-id ", Description: "stable controller-persisted cutover lifecycle id"}, + {Flags: "--session ", Description: "live session identifier"}, + {Flags: "--generation ", Description: "strictly increasing migration generation", DefaultValue: 0}, + {Flags: "--timeout ", Description: "checkpoint deadline", DefaultValue: "30s"}, + {Flags: "--ttl ", Description: "server receipt TTL"}, + {Flags: "--json", Description: "emit the machine contract", DefaultValue: false}, + }, + }, + { + Name: "resume-seal", + Description: "Resume a sealed mount checkpoint on the destination host", + Hidden: true, + flagSource: "runMountResumeSeal", + Options: sealOptions("resume readiness deadline"), + }, + { + Name: "verify-seal", + Description: "Verify a resumed mount checkpoint and recover if needed", + Hidden: true, + flagSource: "runMountVerifySeal", + Options: sealOptions("verification and recovery deadline"), + }, + { + Name: "handback-seal", + Description: "Drain and hand a verified mount back to its original host", + Hidden: true, + flagSource: "runMountHandbackSeal", + Options: sealOptions("final drain and handback deadline"), + }, + } +} + +// mountSealDispatch routes `relayfile mount ` to the machine-contract +// seal commands. It is keyed by the same names mountSealCommands declares; the +// drift test asserts the two agree. +var mountSealDispatch = map[string]func(cliInvocation) error{ + "checkpoint-seal": func(inv cliInvocation) error { + return runMountCheckpointSeal(inv.args, inv.stdout) + }, + "resume-seal": func(inv cliInvocation) error { + return runMountResumeSeal(inv.args, inv.stdin, inv.stdout) + }, + "verify-seal": func(inv cliInvocation) error { + return runMountVerifySeal(inv.args, inv.stdin, inv.stdout) + }, + "handback-seal": func(inv cliInvocation) error { + return runMountHandbackSeal(inv.args, inv.stdin, inv.stdout) + }, +} + +// lookupCommand resolves a top-level command by name or alias. +func lookupCommand(name string) (cliCommandSpec, bool) { + for _, command := range relayfileCommands() { + if command.Name == name { + return command, true + } + for _, alias := range command.Aliases { + if alias == name { + return command, true + } + } + } + return cliCommandSpec{}, false +} + +// publicCommandSpec strips the internal bookkeeping and the commands that are +// not part of the product surface, leaving exactly what the contract describes. +func publicCommandSpec() []cliCommandSpec { + return filterInternalCommands(relayfileCommands()) +} + +func filterInternalCommands(commands []cliCommandSpec) []cliCommandSpec { + public := make([]cliCommandSpec, 0, len(commands)) + for _, command := range commands { + if command.internal { + continue + } + command.dispatch = nil + command.Subcommands = filterInternalCommands(command.Subcommands) + public = append(public, command) + } + return public +} + +// runCommandSpec emits the public command tree as JSON. +func runCommandSpec(args []string, stdout io.Writer) error { + fs := flag.NewFlagSet(commandSpecCommandName, flag.ContinueOnError) + fs.SetOutput(io.Discard) + asJSON := fs.Bool("json", false, "emit the command tree as JSON") + if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{"json": false})); err != nil { + return err + } + if fs.NArg() > 0 { + return fmt.Errorf("usage: relayfile %s --json", commandSpecCommandName) + } + if !*asJSON { + return errors.New("usage: relayfile " + commandSpecCommandName + " --json") + } + + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + encoder.SetEscapeHTML(false) + return encoder.Encode(publicCommandSpec()) +} + +// commandNamesForUsage lists the routable top-level names, aliases included, +// for error messages. +func commandNamesForUsage() string { + names := make([]string, 0) + for _, command := range relayfileCommands() { + if command.internal || command.Hidden { + continue + } + names = append(names, command.Name) + } + return strings.Join(names, ", ") +} diff --git a/cmd/relayfile-cli/commandspec_test.go b/cmd/relayfile-cli/commandspec_test.go new file mode 100644 index 00000000..55970cc0 --- /dev/null +++ b/cmd/relayfile-cli/commandspec_test.go @@ -0,0 +1,469 @@ +package main + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "testing" +) + +// These tests are the relayfile half of the CLI-surface drift guard. The TS +// side (packages/sdk/typescript/src/relay-cli) asserts its snapshot matches +// what `relayfile __command-spec --json` emits; here we assert that emitted +// tree matches what the Go code actually routes and actually parses. +// +// Top-level names need no test: run() dispatches through the same table, so a +// top-level command cannot be declared without being routable. Nested +// subcommands still route through per-group switches, and every leaf still +// builds its own flag.FlagSet, so those two are checked against the source. + +// commandNameRe matches the contract's conformance rule for command names. +var commandNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// flagStringRe matches the contract's conformance rule for flag strings. +var flagStringRe = regexp.MustCompile(`^(-[A-Za-z0-9], )?--[a-z0-9][a-z0-9-]*( [<\[][^>\]]+[>\]])?$`) + +func TestCommandSpecNamesAndFlagsSatisfyContract(t *testing.T) { + // The emitted spec is consumed by @agent-relay/cli-surface's + // assertSurfaceConforms. Catch a violation here, where the failure names + // the Go table, instead of in the TS drift test. + walkSpec(publicCommandSpec(), nil, func(path []string, command cliCommandSpec) { + label := strings.Join(path, " ") + if !commandNameRe.MatchString(command.Name) { + t.Errorf("command %q: name is not lowercase kebab-case", label) + } + if strings.TrimSpace(command.Description) == "" { + t.Errorf("command %q: description must not be empty", label) + } + for _, alias := range command.Aliases { + if !commandNameRe.MatchString(alias) { + t.Errorf("command %q: alias %q is not lowercase kebab-case", label, alias) + } + } + + sawOptional := false + for index, arg := range command.Args { + if arg.Required && sawOptional { + t.Errorf("command %q: required arg %q cannot follow an optional arg", label, arg.Name) + } + if !arg.Required { + sawOptional = true + } + if arg.Variadic && index != len(command.Args)-1 { + t.Errorf("command %q: variadic arg %q must be the last positional", label, arg.Name) + } + if strings.TrimSpace(arg.Description) == "" { + t.Errorf("command %q: arg %q needs a description", label, arg.Name) + } + } + + seen := map[string]bool{} + for _, option := range command.Options { + if !flagStringRe.MatchString(option.Flags) { + t.Errorf("command %q: flags %q is not a commander flag string", label, option.Flags) + } + long := longFlagName(option.Flags) + if long == "" { + continue + } + if seen[long] { + t.Errorf("command %q: duplicate flag --%s", label, long) + } + seen[long] = true + if strings.TrimSpace(option.Description) == "" { + t.Errorf("command %q: flag --%s needs a description", label, long) + } + } + }) +} + +func TestCommandSpecIsJSONSerializable(t *testing.T) { + payload, err := json.Marshal(publicCommandSpec()) + if err != nil { + t.Fatalf("marshal command spec: %v", err) + } + var roundTripped []map[string]any + if err := json.Unmarshal(payload, &roundTripped); err != nil { + t.Fatalf("unmarshal command spec: %v", err) + } + if len(roundTripped) != len(publicCommandSpec()) { + t.Fatalf("round-trip changed command count: %d -> %d", len(publicCommandSpec()), len(roundTripped)) + } +} + +func TestCommandSpecExcludesOnlyInternalHooks(t *testing.T) { + // `help` and `__command-spec` are deliberately routable-but-unpublished: + // the host CLI renders help from the spec, and __command-spec is the + // introspection hook that produces the spec. Anything else missing from + // the published tree is drift. + published := map[string]bool{} + for _, command := range publicCommandSpec() { + published[command.Name] = true + } + var unpublished []string + for _, command := range relayfileCommands() { + if !published[command.Name] { + unpublished = append(unpublished, command.Name) + } + } + sort.Strings(unpublished) + want := []string{commandSpecCommandName, "help"} + sort.Strings(want) + if strings.Join(unpublished, ",") != strings.Join(want, ",") { + t.Fatalf("unpublished top-level commands = %v, want %v", unpublished, want) + } +} + +func TestEveryDeclaredCommandIsDispatchable(t *testing.T) { + for _, command := range relayfileCommands() { + if command.dispatch == nil { + t.Errorf("top-level command %q declares no dispatch", command.Name) + } + } +} + +func TestMountSealSubcommandsMatchDispatch(t *testing.T) { + declared := map[string]bool{} + for _, command := range mountSealCommands() { + declared[command.Name] = true + } + for name := range mountSealDispatch { + if !declared[name] { + t.Errorf("mount subcommand %q is routable but not declared", name) + } + } + for name := range declared { + if _, ok := mountSealDispatch[name]; !ok { + t.Errorf("mount subcommand %q is declared but not routable", name) + } + } +} + +// TestSubcommandsMatchSourceSwitches parses each group's dispatch function out +// of the source and asserts its switch cases are exactly the subcommands the +// table declares (names plus aliases). +func TestSubcommandsMatchSourceSwitches(t *testing.T) { + sources := parseCommandSources(t) + + walkSpec(publicCommandSpec(), nil, func(path []string, command cliCommandSpec) { + if command.dispatchSource == "" { + if len(command.Subcommands) > 0 && !isMountCommand(path) { + t.Errorf("command %q declares subcommands but names no dispatchSource", strings.Join(path, " ")) + } + return + } + + routed, ok := sources.switchCases(command.dispatchSource) + if !ok { + t.Fatalf("command %q: dispatchSource %q not found in cmd/relayfile-cli", strings.Join(path, " "), command.dispatchSource) + } + + declared := map[string]bool{} + for _, sub := range command.Subcommands { + declared[sub.Name] = true + for _, alias := range sub.Aliases { + declared[alias] = true + } + } + + for _, name := range routed { + if !declared[name] { + t.Errorf("%s routes %q but the command table does not declare it", command.dispatchSource, name) + } + } + for name := range declared { + if !contains(routed, name) { + t.Errorf("command table declares %q under %q but %s does not route it", name, strings.Join(path, " "), command.dispatchSource) + } + } + }) +} + +// TestOptionsMatchSourceFlagSets parses each command's flag.FlagSet out of the +// source and asserts the declared options cover exactly the flags it +// registers. Flags whose names are not kebab-case cannot be expressed by the +// contract, so they are allowed to exist undeclared as long as a kebab-case +// alias for them is declared (see --opId / --op-id). +func TestOptionsMatchSourceFlagSets(t *testing.T) { + sources := parseCommandSources(t) + + walkSpec(publicCommandSpec(), nil, func(path []string, command cliCommandSpec) { + label := strings.Join(path, " ") + if command.flagSource == "" { + if len(command.Options) > 0 && !isPassThroughCommand(path) { + t.Errorf("command %q declares options but names no flagSource", label) + } + return + } + + registered, ok := sources.flagNames(command.flagSource) + if !ok { + t.Fatalf("command %q: flagSource %q not found in cmd/relayfile-cli", label, command.flagSource) + } + + declared := map[string]bool{} + for _, option := range command.Options { + if long := longFlagName(option.Flags); long != "" { + declared[long] = true + } + } + + for _, name := range registered { + if declared[name] { + continue + } + if !commandNameRe.MatchString(name) { + // Not expressible as a contract flag string; a kebab-case + // alias must be declared in its place. + continue + } + t.Errorf("%s registers --%s but command %q does not declare it", command.flagSource, name, label) + } + for name := range declared { + if !contains(registered, name) { + t.Errorf("command %q declares --%s but %s does not register it", label, name, command.flagSource) + } + } + }) +} + +func isMountCommand(path []string) bool { + return len(path) == 1 && path[0] == "mount" +} + +// isPassThroughCommand reports whether a command forwards its argv to another +// command rather than parsing flags itself, so its declared options describe +// what the downstream command accepts. +func isPassThroughCommand(path []string) bool { + return strings.Join(path, " ") == "supervisor install" +} + +func longFlagName(flags string) string { + match := regexp.MustCompile(`--([A-Za-z0-9][A-Za-z0-9-]*)`).FindStringSubmatch(flags) + if match == nil { + return "" + } + return match[1] +} + +func contains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func walkSpec(commands []cliCommandSpec, prefix []string, visit func(path []string, command cliCommandSpec)) { + for _, command := range commands { + path := append(append([]string{}, prefix...), command.Name) + visit(path, command) + walkSpec(command.Subcommands, path, visit) + } +} + +// commandSources holds the parsed cmd/relayfile-cli source, indexed by +// function name. +type commandSources struct { + functions map[string]*ast.FuncDecl +} + +func parseCommandSources(t *testing.T) *commandSources { + t.Helper() + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("read cmd/relayfile-cli: %v", err) + } + sources := &commandSources{functions: map[string]*ast.FuncDecl{}} + fset := token.NewFileSet() + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || filepath.Ext(name) != ".go" || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, name, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv != nil || fn.Body == nil { + continue + } + sources.functions[fn.Name.Name] = fn + } + } + if len(sources.functions) == 0 { + t.Fatal("parsed no functions from cmd/relayfile-cli") + } + return sources +} + +// switchCases returns the string case values of every switch statement in the +// named function, in source order. +func (s *commandSources) switchCases(function string) ([]string, bool) { + fn, ok := s.functions[function] + if !ok { + return nil, false + } + var cases []string + ast.Inspect(fn.Body, func(node ast.Node) bool { + clause, ok := node.(*ast.CaseClause) + if !ok { + return true + } + for _, expr := range clause.List { + if value, ok := stringLiteral(expr); ok { + cases = append(cases, value) + } + } + return true + }) + return cases, true +} + +// flagNames returns every flag name registered on a flag.FlagSet inside the +// named function: fs.String("x", ...), fs.Bool, fs.Int, fs.Duration, +// fs.Float64, fs.Var(&v, "x", ...) and friends. +func (s *commandSources) flagNames(function string) ([]string, bool) { + fn, ok := s.functions[function] + if !ok { + return nil, false + } + kinds := map[string]int{ + "String": 0, + "Bool": 0, + "Int": 0, + "Int64": 0, + "Uint": 0, + "Uint64": 0, + "Float64": 0, + "Duration": 0, + "Var": 1, // fs.Var(value, name, usage) + "Func": 0, + } + var names []string + ast.Inspect(fn.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + receiver, ok := selector.X.(*ast.Ident) + if !ok || !isFlagSetReceiver(receiver.Name) { + return true + } + index, ok := kinds[selector.Sel.Name] + if !ok || len(call.Args) <= index { + return true + } + if name, ok := stringLiteral(call.Args[index]); ok { + names = append(names, name) + } + return true + }) + return names, true +} + +// isFlagSetReceiver reports whether an identifier is one of the FlagSet +// variables the CLI uses. `peek` is runDev's throwaway pre-parse set, which +// registers a subset of runListen's flags; excluding it keeps runDev's +// declared options tied to the real parser. +func isFlagSetReceiver(name string) bool { + return name == "fs" +} + +func stringLiteral(expr ast.Expr) (string, bool) { + literal, ok := expr.(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + return "", false + } + value, err := strconv.Unquote(literal.Value) + if err != nil { + return "", false + } + return value, true +} + +// TestCommandSpecJSONShape locks the wire shape the TS snapshot consumes. +func TestCommandSpecJSONShape(t *testing.T) { + payload, err := json.Marshal(publicCommandSpec()) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var tree []map[string]any + if err := json.Unmarshal(payload, &tree); err != nil { + t.Fatalf("unmarshal: %v", err) + } + allowed := map[string]bool{ + "name": true, "description": true, "aliases": true, "args": true, + "options": true, "subcommands": true, "hidden": true, "deprecated": true, + } + var check func(nodes []map[string]any, path string) + check = func(nodes []map[string]any, path string) { + for _, node := range nodes { + name, _ := node["name"].(string) + label := strings.TrimPrefix(path+" "+name, " ") + for key := range node { + if !allowed[key] { + t.Errorf("command %q emits unexpected key %q", label, key) + } + } + if _, ok := node["description"].(string); !ok { + t.Errorf("command %q emits no description", label) + } + if raw, ok := node["subcommands"]; ok { + list, ok := raw.([]any) + if !ok { + t.Fatalf("command %q: subcommands is %T", label, raw) + } + children := make([]map[string]any, 0, len(list)) + for _, item := range list { + child, ok := item.(map[string]any) + if !ok { + t.Fatalf("command %q: subcommand is %T", label, item) + } + children = append(children, child) + } + check(children, label) + } + } + } + check(tree, "") +} + +// TestCommandSpecCommandEmitsJSON exercises the introspection subcommand the +// SDK's generator shells out to. +func TestCommandSpecCommandEmitsJSON(t *testing.T) { + var out strings.Builder + if err := run([]string{commandSpecCommandName, "--json"}, nil, &out, &out); err != nil { + t.Fatalf("run %s --json: %v", commandSpecCommandName, err) + } + var tree []cliCommandSpec + if err := json.Unmarshal([]byte(out.String()), &tree); err != nil { + t.Fatalf("decode emitted spec: %v\n%s", err, out.String()) + } + if len(tree) != len(publicCommandSpec()) { + t.Fatalf("emitted %d commands, table has %d", len(tree), len(publicCommandSpec())) + } + if fmt.Sprint(tree[0].Name) != publicCommandSpec()[0].Name { + t.Fatalf("first emitted command = %q, want %q", tree[0].Name, publicCommandSpec()[0].Name) + } + + var bare strings.Builder + if err := run([]string{commandSpecCommandName}, nil, &bare, &bare); err == nil { + t.Fatal("expected an error without --json") + } +} diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index ecb72269..9f129352 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -637,77 +637,20 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { ) } - switch args[0] { - case "setup": - return runSetup(args[1:], stdin, stdout) - case "login": - return runLogin(args[1:], stdin, stdout) - case "logout": - return runLogout(args[1:], stdout) - case "workspace": - return runWorkspace(args[1:], stdin, stdout) - case "integration": - return runIntegration(args[1:], stdin, stdout) - case "ops": - return runOps(args[1:], stdin, stdout) - case "writeback": - return runWriteback(args[1:], stdout) - case "digest": - return runDigest(args[1:], stdout) - case "pull": - return runPull(args[1:], stdout) - case "mount", "start", "on": - // `start` and `on` are friendlier aliases for `mount`. Same flags, - // same foreground/background behavior; pass --background to detach. - // `on` migrates the agent-relay `relay on` mount UX into relayfile. - if len(args) > 1 && args[1] == "checkpoint-seal" { - return runMountCheckpointSeal(args[2:], stdout) - } - if len(args) > 1 && args[1] == "resume-seal" { - return runMountResumeSeal(args[2:], stdin, stdout) - } - if len(args) > 1 && args[1] == "verify-seal" { - return runMountVerifySeal(args[2:], stdin, stdout) - } - if len(args) > 1 && args[1] == "handback-seal" { - return runMountHandbackSeal(args[2:], stdin, stdout) - } - return runMount(args[1:]) - case "restart": - return runRestart(args[1:], stdout) - case "supervisor": - return runSupervisor(args[1:], stdout) - case "tree", "ls": - return runTree(args[1:], stdout) - case "read", "cat": - return runRead(args[1:], stdout) - case "seed": - return runSeed(args[1:], stdout) - case "export": - return runExport(args[1:], stdout) - case "status": - return runStatus(args[1:], stdout) - case "stop", "off": - // `off` is the friendlier alias for `stop`, migrating the - // agent-relay `relay off` unmount UX into relayfile. - return runStop(args[1:], stdout) - case "logs": - return runLogs(args[1:], stdout) - case "observer": - return runObserver(args[1:], stdout) - case "listen", "watch": - return runListen(args[1:], stdout) - case "control-plane": - return runControlPlane(args[1:], stdout) - case "dev": - return runDev(args[1:], nil, stdout) - case "help", "-h", "--help": - printUsage(stdout) - return nil - default: - printUsage(stderr) - return fmt.Errorf("unknown subcommand %q", args[0]) + // Dispatch through the declared command table in commandspec.go so the + // tree relayfile advertises (and that @relayfile/sdk/relay-cli snapshots + // for `agent-relay file`) cannot drift from the tree it actually routes. + if command, ok := lookupCommand(args[0]); ok { + return command.dispatch(cliInvocation{ + args: args[1:], + stdin: stdin, + stdout: stdout, + stderr: stderr, + }) } + + printUsage(stderr) + return fmt.Errorf("unknown subcommand %q", args[0]) } func quickStartSetupArgs() []string { @@ -914,7 +857,7 @@ func printWritebackUsage(w io.Writer, subcommand string) { case "delete": fmt.Fprintln(w, "Usage: relayfile writeback delete LOCAL_PATH [--workspace WS] [--json] [--timeout 90s]") case "retry": - fmt.Fprintln(w, "Usage: relayfile writeback retry --opId OP [WORKSPACE]") + fmt.Fprintln(w, "Usage: relayfile writeback retry --op-id OP [WORKSPACE]") case "skip-stuck": fmt.Fprintln(w, "Usage: relayfile writeback skip-stuck [WORKSPACE] [--workspace WS] [--max N] [--json]") case "sweep-drafts": @@ -923,7 +866,7 @@ func printWritebackUsage(w io.Writer, subcommand string) { fmt.Fprintln(w, `Usage: relayfile writeback list --state pending|dead [--workspace WS] [--json] relayfile writeback status [WORKSPACE] [--json] - relayfile writeback retry --opId OP [WORKSPACE] + 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] @@ -972,7 +915,7 @@ Usage: relayfile ops replay OPID [--workspace NAME] relayfile writeback list --state pending|dead [--workspace WS] [--json] relayfile writeback status [WORKSPACE] [--json] - relayfile writeback retry --opId OP [WORKSPACE] + 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] @@ -5668,15 +5611,23 @@ func runWritebackRetry(args []string, stdout io.Writer) error { fs := flag.NewFlagSet("writeback retry", flag.ContinueOnError) fs.SetOutput(io.Discard) opID := fs.String("opId", "", "dead-lettered operation id") + // --op-id is the kebab-case spelling the relay CLI surface contract + // requires of a declared flag. --opId stays accepted so existing scripts + // keep working. + opIDKebab := fs.String("op-id", "", "dead-lettered operation id (alias for --opId)") if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{ - "opId": true, + "opId": true, + "op-id": true, })); err != nil { return err } if fs.NArg() > 1 { - return errors.New("usage: relayfile writeback retry --opId OP [WORKSPACE]") + return errors.New("usage: relayfile writeback retry --op-id OP [WORKSPACE]") } op := strings.TrimSpace(*opID) + if op == "" { + op = strings.TrimSpace(*opIDKebab) + } if op == "" { return errors.New("opId is required") } diff --git a/cmd/relayfile-cli/main_test.go b/cmd/relayfile-cli/main_test.go index 06654c30..1bd2437c 100644 --- a/cmd/relayfile-cli/main_test.go +++ b/cmd/relayfile-cli/main_test.go @@ -405,12 +405,12 @@ func TestHelpFlagPrintsUsageForCommandsAndSubcommands(t *testing.T) { {name: "ops group", args: []string{"ops", "-h"}, want: "relayfile ops replay OPID"}, {name: "ops list", args: []string{"ops", "list", "-h"}, want: "Usage: relayfile ops list"}, {name: "ops replay", args: []string{"ops", "replay", "-h"}, want: "Usage: relayfile ops replay OPID"}, - {name: "writeback group", args: []string{"writeback", "-h"}, want: "relayfile writeback retry --opId OP"}, + {name: "writeback group", args: []string{"writeback", "-h"}, want: "relayfile writeback retry --op-id OP"}, {name: "writeback list", args: []string{"writeback", "list", "-h"}, want: writebackListUsage}, {name: "writeback status", args: []string{"writeback", "status", "-h"}, want: "Usage: relayfile writeback status"}, {name: "writeback update", args: []string{"writeback", "update", "-h"}, want: "Usage: relayfile writeback update"}, {name: "writeback delete", args: []string{"writeback", "delete", "-h"}, want: "Usage: relayfile writeback delete"}, - {name: "writeback retry", args: []string{"writeback", "retry", "-h"}, want: "Usage: relayfile writeback retry --opId OP"}, + {name: "writeback retry", args: []string{"writeback", "retry", "-h"}, want: "Usage: relayfile writeback retry --op-id OP"}, {name: "digest group", args: []string{"digest", "-h"}, want: "today|yesterday|YYYY-MM-DD|this-week|last-week"}, {name: "digest rebuild", args: []string{"digest", "rebuild", "-h"}, want: digestRebuildUsage}, {name: "pull", args: []string{"pull", "-h"}, want: "Usage: relayfile pull"}, diff --git a/package-lock.json b/package-lock.json index 803c44db..81bf149c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,18 @@ "tsx": "^4.21.0" } }, + "../relay/packages/cli-surface": { + "name": "@agent-relay/cli-surface", + "version": "12.2.2", + "dev": true, + "devDependencies": { + "@types/node": "^22.19.3", + "vitest": "^4.1.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@agent-assistant/connectivity": { "version": "0.2.24", "resolved": "https://registry.npmjs.org/@agent-assistant/connectivity/-/connectivity-0.2.24.tgz", @@ -470,6 +482,10 @@ "win32" ] }, + "node_modules/@agent-relay/cli-surface": { + "resolved": "../relay/packages/cli-surface", + "link": true + }, "node_modules/@agent-relay/config": { "version": "4.0.28", "resolved": "https://registry.npmjs.org/@agent-relay/config/-/config-4.0.28.tgz", @@ -4860,7 +4876,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4882,7 +4897,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4904,7 +4918,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4926,7 +4939,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4948,7 +4960,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4970,7 +4981,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4992,7 +5002,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -5014,7 +5023,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -5036,7 +5044,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -5058,7 +5065,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -5080,7 +5086,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7628,6 +7633,9 @@ "version": "0.10.56", "hasInstallScript": true, "license": "Apache-2.0", + "dependencies": { + "@relayfile/sdk": "0.10.56" + }, "bin": { "relayfile": "scripts/run.js" }, @@ -8497,6 +8505,7 @@ "tar": "^7.5.10" }, "devDependencies": { + "@agent-relay/cli-surface": "file:../../../../relay/packages/cli-surface", "typescript": "^5.7.3", "vitest": "^3.0.0" }, diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index e6e0b441..cf89bb9a 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased - Patch] +### Added + +- Hidden `relayfile __command-spec --json` subcommand, which emits relayfile's command tree as JSON. It is how `@relayfile/sdk/relay-cli` — the surface the `agent-relay` CLI mounts as `agent-relay file` — knows relayfile's commands without the binary being present. `relayfile writeback retry` also accepts `--op-id` as a kebab-case alias for `--opId`, which stays supported. + +### Changed + +- The `relayfile` bin shim no longer carries its own copies of binary resolution or the Cloud sign-in preflight; both moved into `@relayfile/sdk/relay-cli` (a new dependency of this package) so the `relayfile` command and `agent-relay file` share one implementation and cannot diverge. `relayfile --version` still answers without any binary lookup, and every command keeps its existing behavior, stdio, and exit codes. + ### Fixed - `relayfile-mount --once` now treats a per-cycle deadline during an incomplete bootstrap as resumable: it reports `mount bootstrapping: bootstrap incomplete (in progress)` and exits 0 so a later bounded run can resume. A root-context deadline remains a nonzero failure. diff --git a/packages/cli/package.json b/packages/cli/package.json index c8da17c0..9e07821e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,5 +34,8 @@ ], "engines": { "node": ">=18" + }, + "dependencies": { + "@relayfile/sdk": "0.10.56" } } diff --git a/packages/cli/scripts/cloud-auth.test.js b/packages/cli/scripts/cloud-auth.test.js new file mode 100644 index 00000000..d940b4fa --- /dev/null +++ b/packages/cli/scripts/cloud-auth.test.js @@ -0,0 +1,170 @@ +"use strict"; + +// Tests for the vendored Agent Relay Cloud SDK bundle that ships in this +// package. The preflight *logic* that drives it now lives in +// @relayfile/sdk/relay-cli (single-homed so `relayfile` and +// `agent-relay file` behave identically) and is tested there, in +// packages/sdk/typescript/src/relay-cli/cloud-preflight.test.ts. What is +// tested here is the bundle itself plus this shim's use of it. + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const test = require("node:test"); + +const bundlePath = path.join(__dirname, "cloud-auth.cjs"); + +test("the vendored bundle is present for the preflight to load", () => { + assert.equal(fs.existsSync(bundlePath), true); + assert.equal( + typeof require(bundlePath).ensureCloudSession, + "function", + ); +}); + +test("bundled SDK carries the Relayfile marker through both login modes", () => { + const bundledSdk = fs.readFileSync(bundlePath, "utf8"); + assert.match( + bundledSdk, + /loginUrl\.searchParams\.set\("client", options\.client\)/, + ); + assert.match(bundledSdk, /clientName: options\.client/); + assert.match(bundledSdk, /signal: options\.signal/); + assert.match(bundledSdk, /throwIfAborted\(options\.signal\)/); +}); + +test("bundled SDK aborts device polling without issuing or storing credentials", () => { + const script = ` + const { ensureCloudSession } = require(${JSON.stringify(bundlePath)}); + const controller = new AbortController(); + let fetchCalls = 0; + global.fetch = async () => { + fetchCalls += 1; + return { + ok: true, + status: 200, + json: async () => ({ + device_code: "device-test", + user_code: "TEST-CODE", + verification_uri: "https://example.test/device", + expires_in: 600, + interval: 5, + }), + }; + }; + console.log = () => {}; + const auth = ensureCloudSession({ + apiUrl: "https://example.test/cloud", + client: "relayfile", + device: true, + force: true, + signal: controller.signal, + }); + setTimeout(() => controller.abort(new Error("preflight cancelled")), 10); + auth.then( + () => process.exit(2), + (error) => process.exit(error.message === "preflight cancelled" && fetchCalls === 1 ? 0 : 3), + ); + `; + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf8", + timeout: 2000, + }); + assert.equal(result.status, 0, result.stderr || result.stdout); +}); + +test("bundled SDK handles browser-launch errors and honors the login timeout", () => { + const script = ` + const os = require("node:os"); + os.platform = () => "linux"; + process.env.PATH = ""; + const { ensureCloudSession } = require(${JSON.stringify(bundlePath)}); + console.log = () => {}; + const startedAt = Date.now(); + ensureCloudSession({ + apiUrl: "https://example.test/cloud", + client: "relayfile", + interactive: true, + device: false, + force: true, + env: { DISPLAY: ":99" }, + loginTimeoutMs: 25, + }).then( + () => process.exit(2), + (error) => { + const elapsedMs = Date.now() - startedAt; + const passed = + error.message === "Timed out waiting for browser login" && + elapsedMs < 1000; + setTimeout(() => process.exit(passed ? 0 : 3), 25); + }, + ); + `; + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf8", + timeout: 2000, + }); + assert.equal(result.status, 0, result.stderr || result.stdout); +}); + +test("the SDK preflight keeps canonical auth out of the child environment", () => { + // End-to-end through the real preflight in @relayfile/sdk/relay-cli, loading + // the real bundle from this package, against a real on-disk session. + const home = fs.mkdtempSync(path.join(os.tmpdir(), "relayfile-cloud-sdk-")); + const authDir = path.join(home, ".agentworkforce", "relay"); + fs.mkdirSync(authDir, { recursive: true, mode: 0o700 }); + const authPath = path.join(authDir, "cloud-auth.json"); + fs.writeFileSync( + authPath, + `${JSON.stringify({ + apiUrl: "https://cloud.example", + accessToken: "cld_at_bundle_secret", + refreshToken: "cld_rt_bundle_secret", + accessTokenExpiresAt: "2099-08-23T14:00:00Z", + refreshTokenExpiresAt: "2099-09-23T14:00:00Z", + })}\n`, + { mode: 0o600 }, + ); + + const script = ` + const fs = require("node:fs"); + import("@relayfile/sdk/relay-cli").then(({ prepareCloudSession }) => + prepareCloudSession([], { + env: process.env, + cloudAuthBundlePath: ${JSON.stringify(bundlePath)}, + }), + ).then(() => { + const stored = JSON.parse(fs.readFileSync(${JSON.stringify(authPath)}, "utf8")); + console.log(JSON.stringify({ + apiUrl: stored.apiUrl, + hasAccess: Boolean(process.env.CLOUD_API_ACCESS_TOKEN), + hasRefresh: Boolean(process.env.CLOUD_API_REFRESH_TOKEN), + })); + }).catch((error) => { console.error(error.message); process.exit(1); }); + `; + const childEnv = { ...process.env, HOME: home }; + for (const name of [ + "CLOUD_API_URL", + "CLOUD_API_ACCESS_TOKEN", + "CLOUD_API_REFRESH_TOKEN", + "CLOUD_API_ACCESS_TOKEN_EXPIRES_AT", + "CLOUD_API_REFRESH_TOKEN_EXPIRES_AT", + ]) { + delete childEnv[name]; + } + const result = spawnSync(process.execPath, ["-e", script], { + cwd: __dirname, + encoding: "utf8", + env: childEnv, + }); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { + apiUrl: "https://cloud.example", + hasAccess: false, + hasRefresh: false, + }); + assert.doesNotMatch(result.stdout, /cld_[ar]t_bundle_secret/); +}); diff --git a/packages/cli/scripts/cloud-preflight.js b/packages/cli/scripts/cloud-preflight.js deleted file mode 100644 index e069065b..00000000 --- a/packages/cli/scripts/cloud-preflight.js +++ /dev/null @@ -1,312 +0,0 @@ -"use strict"; - -const path = require("path"); - -const DEFAULT_CLOUD_API_URL = "https://agentrelay.com/cloud"; -const SETUP_INTENT = - "Relayfile setup. This signs you in, connects an integration, and prepares a local VFS mount."; -const SETUP_INTENT_PRINTED_ENV = "RELAYFILE_NPM_SETUP_INTENT_PRINTED"; -const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1000; -const DEFAULT_REFRESH_TIMEOUT_MS = 10 * 1000; -const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647; -const MAX_GO_DURATION_NANOSECONDS = 9_223_372_036_854_775_807; -const SETUP_FLAGS = new Map([ - ["cloud-api-url", true], - ["cloud-token", true], - ["workspace", true], - ["provider", true], - ["backend", true], - ["local-dir", true], - ["no-open", false], - ["skip-mount", false], - ["once", false], - ["login-timeout", true], - ["connect-timeout", true], - ["help", false], - ["h", false], -]); -const GO_BOOLEAN_VALUES = new Set([ - "1", - "t", - "T", - "true", - "TRUE", - "True", - "0", - "f", - "F", - "false", - "FALSE", - "False", -]); -const GO_TRUE_VALUES = new Set(["1", "t", "T", "true", "TRUE", "True"]); -const GO_DURATION_UNITS_IN_NANOSECONDS = new Map([ - ["ns", 1], - ["us", 1_000], - ["µs", 1_000], - ["μs", 1_000], - ["ms", 1_000_000], - ["s", 1_000_000_000], - ["m", 60 * 1_000_000_000], - ["h", 60 * 60 * 1_000_000_000], -]); -const VALID_INTEGRATION_BACKENDS = new Set([ - "", - "default", - "nango", - "composio", -]); - -function wantsNativeVersion(args) { - return ( - args.length === 1 && (args[0] === "--version" || args[0] === "version") - ); -} - -function wantsNativeHelp(args) { - return args.some((arg) => arg === "--help" || arg === "-h"); -} - -function parseGoDurationMilliseconds(value) { - let remaining = String(value); - let sign = 1; - if (remaining.startsWith("+") || remaining.startsWith("-")) { - sign = remaining[0] === "-" ? -1 : 1; - remaining = remaining.slice(1); - } - if (remaining === "0") { - return 0; - } - if (!remaining) { - return null; - } - - let nanoseconds = 0; - let parts = 0; - while (remaining) { - const match = /^(\d+(?:\.\d*)?|\.\d+)(ns|us|µs|μs|ms|s|m|h)/.exec( - remaining, - ); - if (!match) { - return null; - } - const amount = Number(match[1]); - const unitNanoseconds = GO_DURATION_UNITS_IN_NANOSECONDS.get(match[2]); - nanoseconds += amount * unitNanoseconds; - if ( - !Number.isFinite(nanoseconds) || - nanoseconds > MAX_GO_DURATION_NANOSECONDS - ) { - return null; - } - remaining = remaining.slice(match[0].length); - parts += 1; - } - return parts > 0 ? (sign * nanoseconds) / 1_000_000 : null; -} - -function parseSetupArguments(args) { - const setupArgs = args[0] === "setup" ? args.slice(1) : args; - const values = new Map(); - const durations = new Map(); - for (let index = 0; index < setupArgs.length; index += 1) { - const arg = setupArgs[index]; - if (arg === "--") { - if (index !== setupArgs.length - 1) { - return { valid: false, error: "setup does not accept positional arguments" }; - } - break; - } - - const match = /^--?([^=]+)(?:=(.*))?$/.exec(arg); - if (!match) { - return { - valid: false, - error: `unexpected setup argument ${JSON.stringify(arg)}`, - }; - } - const [, name, inlineValue] = match; - const takesValue = SETUP_FLAGS.get(name); - if (takesValue === undefined) { - return { valid: false, error: `unknown setup flag --${name}` }; - } - if (takesValue) { - let value = inlineValue; - if (inlineValue === undefined) { - const next = setupArgs[index + 1]; - if (next === undefined) { - return { valid: false, error: `--${name} requires a value` }; - } - value = next; - index += 1; - } - values.set(name, value); - if (name === "login-timeout" || name === "connect-timeout") { - const duration = parseGoDurationMilliseconds(value); - if (duration === null) { - return { - valid: false, - error: `--${name} has invalid duration ${JSON.stringify(value)}`, - }; - } - if (name === "login-timeout" && duration <= 0) { - return { - valid: false, - error: "--login-timeout must be greater than zero", - }; - } - durations.set(name, duration); - } - continue; - } - if (inlineValue !== undefined && !GO_BOOLEAN_VALUES.has(inlineValue)) { - return { - valid: false, - error: `--${name} has invalid boolean value ${JSON.stringify(inlineValue)}`, - }; - } - values.set(name, inlineValue === undefined || GO_TRUE_VALUES.has(inlineValue)); - } - - const backend = String(values.get("backend") || "").trim().toLowerCase(); - if (!VALID_INTEGRATION_BACKENDS.has(backend)) { - return { - valid: false, - error: `unsupported integration backend ${JSON.stringify(backend)} (expected nango or composio)`, - }; - } - - return { valid: true, values, durations }; -} - -function hasValidSetupArguments(args) { - return parseSetupArguments(args).valid; -} - -function shouldPrepareCloudSession(args, env) { - const setupCommand = args.length === 0 || args[0] === "setup"; - if (!setupCommand) { - return false; - } - if (wantsNativeVersion(args) || wantsNativeHelp(args)) { - return false; - } - const parsed = parseSetupArguments(args); - if (!parsed.valid) { - return false; - } - if (parsed.values.has("help") || parsed.values.has("h")) { - return false; - } - // Explicit credentials are caller-owned. Let the Go CLI validate and use - // them without replacing them with an interactive session. - const relayfileCloudToken = parsed.values.has("cloud-token") - ? String(parsed.values.get("cloud-token") || "").trim() - : String(env.RELAYFILE_CLOUD_TOKEN || "").trim(); - if ( - relayfileCloudToken || - String(env.CLOUD_API_ACCESS_TOKEN || "").trim() - ) { - return false; - } - // Let the native CLI report malformed flags without first opening a login - // flow or mutating the caller's canonical Cloud session. - return true; -} - -function announceSetupIntent(args, env, writeLine = console.log) { - if (!shouldPrepareCloudSession(args, env)) { - return false; - } - if (String(env[SETUP_INTENT_PRINTED_ENV] || "").trim() !== "1") { - writeLine(SETUP_INTENT); - env[SETUP_INTENT_PRINTED_ENV] = "1"; - } - return true; -} - -function loadCloudSessionSDK() { - const bundlePath = path.join(__dirname, "cloud-auth.cjs"); - try { - return require(bundlePath).ensureCloudSession; - } catch (error) { - throw new Error( - "Relayfile's Agent Relay Cloud SDK bundle is missing. Reinstall relayfile or run its package build.", - { cause: error }, - ); - } -} - -async function prepareCloudSession(args, env = process.env, dependencies = {}) { - const setupCommand = args.length === 0 || args[0] === "setup"; - if (wantsNativeVersion(args) || wantsNativeHelp(args)) { - return false; - } - const parsed = - setupCommand ? parseSetupArguments(args) : null; - if (parsed && !parsed.valid) { - throw new Error(parsed.error); - } - if (!shouldPrepareCloudSession(args, env)) { - return false; - } - - const ensureCloudSession = - dependencies.ensureCloudSession || loadCloudSessionSDK(); - const apiUrl = - String(parsed.values.get("cloud-api-url") || "").trim() || - String(env.RELAYFILE_CLOUD_API_URL || "").trim() || - String(env.CLOUD_API_URL || "").trim() || - DEFAULT_CLOUD_API_URL; - const loginTimeoutMs = - parsed.durations.get("login-timeout") || DEFAULT_LOGIN_TIMEOUT_MS; - const loginAbort = new AbortController(); - let timer; - try { - await Promise.race([ - ensureCloudSession({ - apiUrl, - client: "relayfile", - interactive: true, - device: parsed.values.get("no-open") === true, - loginTimeoutMs, - refreshTimeoutMs: Math.max( - 1, - Math.min(loginTimeoutMs, DEFAULT_REFRESH_TIMEOUT_MS), - ), - signal: loginAbort.signal, - }), - new Promise((_, reject) => { - timer = setTimeout(() => { - const error = new Error( - `Cloud sign-in timed out after ${parsed.values.get("login-timeout") || "5m"}`, - ); - loginAbort.abort(error); - reject(error); - }, Math.min(loginTimeoutMs, MAX_NODE_TIMER_DELAY_MS)); - }), - ]); - } finally { - clearTimeout(timer); - } - - // The SDK owns and refreshes its canonical on-disk session. Do not promote - // that session into CLOUD_API_* for the child: Relayfile would correctly - // treat those variables as caller-owned and would not persist rotated - // refresh tokens back to the shared file. The native runtime reads the same - // canonical file directly. Genuine caller-provided environment credentials - // bypass this preflight above and remain untouched. - return true; -} - -module.exports = { - DEFAULT_CLOUD_API_URL, - SETUP_INTENT, - SETUP_INTENT_PRINTED_ENV, - announceSetupIntent, - hasValidSetupArguments, - parseGoDurationMilliseconds, - parseSetupArguments, - prepareCloudSession, - shouldPrepareCloudSession, -}; diff --git a/packages/cli/scripts/cloud-preflight.test.js b/packages/cli/scripts/cloud-preflight.test.js deleted file mode 100644 index a90eda0a..00000000 --- a/packages/cli/scripts/cloud-preflight.test.js +++ /dev/null @@ -1,491 +0,0 @@ -"use strict"; - -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const { spawnSync } = require("node:child_process"); -const test = require("node:test"); - -const { - SETUP_INTENT, - SETUP_INTENT_PRINTED_ENV, - announceSetupIntent, - hasValidSetupArguments, - parseGoDurationMilliseconds, - prepareCloudSession, - shouldPrepareCloudSession, -} = require("./cloud-preflight.js"); - -test("SDK setup intent is announced once before authentication", () => { - const env = {}; - const lines = []; - assert.equal(announceSetupIntent([], env, (line) => lines.push(line)), true); - assert.equal(announceSetupIntent([], env, (line) => lines.push(line)), true); - assert.deepEqual(lines, [SETUP_INTENT]); - assert.equal(env[SETUP_INTENT_PRINTED_ENV], "1"); -}); - -test("bare relayfile prepares Cloud auth through the Agent Relay SDK", async () => { - const env = {}; - const calls = []; - const prepared = await prepareCloudSession([], env, { - ensureCloudSession: async (options) => { - calls.push(options); - return { - auth: { - apiUrl: "https://cloud.example", - accessToken: "cld_at_test_secret", - refreshToken: "cld_rt_test_secret", - accessTokenExpiresAt: "2026-08-23T14:00:00Z", - refreshTokenExpiresAt: "2026-09-23T14:00:00Z", - }, - }; - }, - }); - - assert.equal(prepared, true); - assert.equal(calls[0].signal instanceof AbortSignal, true); - assert.equal(calls[0].signal.aborted, false); - assert.deepEqual(calls, [ - { - apiUrl: "https://agentrelay.com/cloud", - client: "relayfile", - interactive: true, - device: false, - loginTimeoutMs: 300000, - refreshTimeoutMs: 10000, - signal: calls[0].signal, - }, - ]); - assert.deepEqual(env, {}); -}); - -test("setup forwards its Cloud URL and no-open mode to the SDK", async () => { - const env = {}; - let received; - await prepareCloudSession( - [ - "setup", - "--cloud-api-url=https://staging.example/cloud", - "--no-open", - "--login-timeout=10s", - ], - env, - { - ensureCloudSession: async (options) => { - received = options; - return { - auth: { - apiUrl: options.apiUrl, - accessToken: "access", - refreshToken: "refresh", - accessTokenExpiresAt: "2026-08-23T14:00:00Z", - }, - }; - }, - }, - ); - - assert.deepEqual(received, { - apiUrl: "https://staging.example/cloud", - client: "relayfile", - interactive: true, - device: true, - loginTimeoutMs: 10000, - refreshTimeoutMs: 10000, - signal: received.signal, - }); - assert.equal(received.signal instanceof AbortSignal, true); - assert.deepEqual(env, {}); -}); - -test("bundled SDK carries the Relayfile marker through both login modes", () => { - const bundledSdk = fs.readFileSync( - path.join(__dirname, "cloud-auth.cjs"), - "utf8", - ); - assert.match( - bundledSdk, - /loginUrl\.searchParams\.set\("client", options\.client\)/, - ); - assert.match(bundledSdk, /clientName: options\.client/); - assert.match(bundledSdk, /signal: options\.signal/); - assert.match(bundledSdk, /throwIfAborted\(options\.signal\)/); -}); - -test("bundled SDK aborts device polling without issuing or storing credentials", () => { - const modulePath = path.join(__dirname, "cloud-auth.cjs"); - const script = ` - const { ensureCloudSession } = require(${JSON.stringify(modulePath)}); - const controller = new AbortController(); - let fetchCalls = 0; - global.fetch = async () => { - fetchCalls += 1; - return { - ok: true, - status: 200, - json: async () => ({ - device_code: "device-test", - user_code: "TEST-CODE", - verification_uri: "https://example.test/device", - expires_in: 600, - interval: 5, - }), - }; - }; - console.log = () => {}; - const auth = ensureCloudSession({ - apiUrl: "https://example.test/cloud", - client: "relayfile", - device: true, - force: true, - signal: controller.signal, - }); - setTimeout(() => controller.abort(new Error("preflight cancelled")), 10); - auth.then( - () => process.exit(2), - (error) => process.exit(error.message === "preflight cancelled" && fetchCalls === 1 ? 0 : 3), - ); - `; - const result = spawnSync(process.execPath, ["-e", script], { - encoding: "utf8", - timeout: 2000, - }); - assert.equal(result.status, 0, result.stderr || result.stdout); -}); - -test("bundled SDK handles browser-launch errors and honors the login timeout", () => { - const modulePath = path.join(__dirname, "cloud-auth.cjs"); - const script = ` - const os = require("node:os"); - os.platform = () => "linux"; - process.env.PATH = ""; - const { ensureCloudSession } = require(${JSON.stringify(modulePath)}); - console.log = () => {}; - const startedAt = Date.now(); - ensureCloudSession({ - apiUrl: "https://example.test/cloud", - client: "relayfile", - interactive: true, - device: false, - force: true, - env: { DISPLAY: ":99" }, - loginTimeoutMs: 25, - }).then( - () => process.exit(2), - (error) => { - const elapsedMs = Date.now() - startedAt; - const passed = - error.message === "Timed out waiting for browser login" && - elapsedMs < 1000; - setTimeout(() => process.exit(passed ? 0 : 3), 25); - }, - ); - `; - const result = spawnSync(process.execPath, ["-e", script], { - encoding: "utf8", - timeout: 2000, - }); - assert.equal(result.status, 0, result.stderr || result.stdout); -}); - -test("help and caller-owned tokens do not start interactive auth", () => { - assert.equal(shouldPrepareCloudSession(["setup", "--help"], {}), false); - assert.equal(shouldPrepareCloudSession(["setup", "--help=true"], {}), false); - assert.equal(shouldPrepareCloudSession(["setup", "--help=false"], {}), false); - assert.equal(shouldPrepareCloudSession(["setup", "-h=0"], {}), false); - assert.equal( - shouldPrepareCloudSession(["setup", "--cloud-token", "explicit"], {}), - false, - ); - assert.equal( - shouldPrepareCloudSession(["setup", "-cloud-token", "explicit"], {}), - false, - ); - assert.equal( - shouldPrepareCloudSession(["setup", "--cloud-token="], {}), - true, - ); - assert.equal( - shouldPrepareCloudSession(["setup", "--cloud-token", ""], {}), - true, - ); - assert.equal( - shouldPrepareCloudSession( - ["setup", "--cloud-token="], - { RELAYFILE_CLOUD_TOKEN: "inherited-token" }, - ), - true, - ); - assert.equal( - shouldPrepareCloudSession( - ["setup"], - { RELAYFILE_CLOUD_TOKEN: "inherited-token" }, - ), - false, - ); - assert.equal( - shouldPrepareCloudSession([], { CLOUD_API_ACCESS_TOKEN: "ci-token" }), - false, - ); - assert.equal(shouldPrepareCloudSession(["status"], {}), false); -}); - -test("pseudo-help values never start SDK auth", async () => { - for (const args of [ - ["setup", "--help=false"], - ["setup", "-h=0", "--cloud-token="], - ]) { - let calls = 0; - const prepared = await prepareCloudSession( - args, - { RELAYFILE_CLOUD_TOKEN: "inherited-token" }, - { - ensureCloudSession: async () => { - calls += 1; - }, - }, - ); - assert.equal(prepared, false); - assert.equal(calls, 0); - } -}); - -test("native help short-circuits even when it occupies a setup value slot", async () => { - let calls = 0; - const prepared = await prepareCloudSession( - ["setup", "--provider", "--help"], - {}, - { - ensureCloudSession: async () => { - calls += 1; - }, - }, - ); - assert.equal(prepared, false); - assert.equal(calls, 0); -}); - -test("version only bypasses auth when the native CLI treats it as version", async () => { - assert.equal(shouldPrepareCloudSession(["--version"], {}), false); - assert.equal(shouldPrepareCloudSession(["version"], {}), false); - assert.equal( - shouldPrepareCloudSession(["setup", "--local-dir", "--version"], {}), - true, - ); - - let calls = 0; - const prepared = await prepareCloudSession( - ["setup", "--local-dir", "--version"], - {}, - { - ensureCloudSession: async () => { - calls += 1; - }, - }, - ); - assert.equal(prepared, true); - assert.equal(calls, 1); -}); - -test("dash-prefixed values follow the native setup grammar", async () => { - const args = ["setup", "--local-dir", "-mirror"]; - assert.equal(hasValidSetupArguments(args), true); - let calls = 0; - const prepared = await prepareCloudSession(args, {}, { - ensureCloudSession: async () => { - calls += 1; - }, - }); - assert.equal(prepared, true); - assert.equal(calls, 1); -}); - -test("malformed setup arguments fail before interactive auth", async () => { - assert.equal(hasValidSetupArguments(["setup", "--provider"]), false); - assert.equal(hasValidSetupArguments(["setup", "--unknown"]), false); - assert.equal(hasValidSetupArguments(["setup", "unexpected"]), false); - assert.equal( - shouldPrepareCloudSession(["setup", "--provider"], {}), - false, - ); - assert.equal( - shouldPrepareCloudSession(["setup", "--unknown"], {}), - false, - ); - let authCalls = 0; - await assert.rejects( - prepareCloudSession( - ["setup", "--provider"], - {}, - { - ensureCloudSession: async () => { - authCalls += 1; - throw new Error("interactive auth must not run"); - }, - }, - ), - /--provider requires a value/, - ); - assert.equal(authCalls, 0); -}); - -test("invalid setup values fail before interactive auth", async () => { - assert.equal( - hasValidSetupArguments(["setup", "--connect-timeout=bogus"]), - false, - ); - assert.equal( - hasValidSetupArguments(["setup", "--backend", "invalid"]), - false, - ); - let authCalls = 0; - await assert.rejects( - prepareCloudSession( - ["setup", "--backend", "invalid"], - {}, - { - ensureCloudSession: async () => { - authCalls += 1; - }, - }, - ), - /unsupported integration backend/, - ); - assert.equal(authCalls, 0); -}); - -test("Go durations are validated and converted for SDK login", () => { - assert.equal(parseGoDurationMilliseconds("10s"), 10000); - assert.equal(parseGoDurationMilliseconds("1m30.5s"), 90500); - assert.equal(parseGoDurationMilliseconds("250ms"), 250); - assert.equal(parseGoDurationMilliseconds("bogus"), null); - assert.equal(parseGoDurationMilliseconds("10"), null); -}); - -test("login timeout bounds SDK authentication", async () => { - let receivedSignal; - let lateCredentialWrite = false; - await assert.rejects( - prepareCloudSession( - ["setup", "--login-timeout=1ms"], - {}, - { - ensureCloudSession: ({ signal }) => { - receivedSignal = signal; - return new Promise((resolve, reject) => { - const lateWrite = setTimeout(() => { - lateCredentialWrite = true; - resolve(); - }, 25); - signal.addEventListener( - "abort", - () => { - clearTimeout(lateWrite); - reject(signal.reason); - }, - { once: true }, - ); - }); - }, - }, - ), - /Cloud sign-in timed out after 1ms/, - ); - assert.equal(receivedSignal.aborted, true); - assert.match(receivedSignal.reason.message, /timed out after 1ms/); - await new Promise((resolve) => setTimeout(resolve, 30)); - assert.equal(lateCredentialWrite, false); -}); - -test("false no-open values keep browser login enabled", async () => { - for (const value of ["false", "0"]) { - let received; - await prepareCloudSession( - ["setup", `--no-open=${value}`], - {}, - { - ensureCloudSession: async (options) => { - received = options; - }, - }, - ); - assert.equal(received.device, false); - } -}); - -test("valid explicit setup arguments still prepare Cloud auth", () => { - assert.equal( - hasValidSetupArguments([ - "setup", - "--provider", - "github", - "--workspace=frontend", - "--once", - "--no-open=true", - ]), - true, - ); - assert.equal( - shouldPrepareCloudSession( - ["setup", "--provider", "github", "--workspace=frontend", "--once"], - {}, - ), - true, - ); -}); - -test("the bundled SDK keeps canonical auth out of the child environment", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "relayfile-cloud-sdk-")); - const authDir = path.join(home, ".agentworkforce", "relay"); - fs.mkdirSync(authDir, { recursive: true, mode: 0o700 }); - fs.writeFileSync( - path.join(authDir, "cloud-auth.json"), - `${JSON.stringify({ - apiUrl: "https://cloud.example", - accessToken: "cld_at_bundle_secret", - refreshToken: "cld_rt_bundle_secret", - accessTokenExpiresAt: "2099-08-23T14:00:00Z", - refreshTokenExpiresAt: "2099-09-23T14:00:00Z", - })}\n`, - { mode: 0o600 }, - ); - - const modulePath = path.join(__dirname, "cloud-preflight.js"); - const authPath = path.join(authDir, "cloud-auth.json"); - const script = ` - const fs = require("node:fs"); - const { prepareCloudSession } = require(${JSON.stringify(modulePath)}); - prepareCloudSession([], process.env).then(() => { - const stored = JSON.parse(fs.readFileSync(${JSON.stringify(authPath)}, "utf8")); - console.log(JSON.stringify({ - apiUrl: stored.apiUrl, - hasAccess: Boolean(process.env.CLOUD_API_ACCESS_TOKEN), - hasRefresh: Boolean(process.env.CLOUD_API_REFRESH_TOKEN), - })); - }).catch((error) => { console.error(error.message); process.exit(1); }); - `; - const childEnv = { ...process.env, HOME: home }; - for (const name of [ - "CLOUD_API_URL", - "CLOUD_API_ACCESS_TOKEN", - "CLOUD_API_REFRESH_TOKEN", - "CLOUD_API_ACCESS_TOKEN_EXPIRES_AT", - "CLOUD_API_REFRESH_TOKEN_EXPIRES_AT", - ]) { - delete childEnv[name]; - } - const result = spawnSync(process.execPath, ["-e", script], { - encoding: "utf8", - env: childEnv, - }); - - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(JSON.parse(result.stdout), { - apiUrl: "https://cloud.example", - hasAccess: false, - hasRefresh: false, - }); - assert.doesNotMatch(result.stdout, /cld_[ar]t_bundle_secret/); -}); diff --git a/packages/cli/scripts/install.js b/packages/cli/scripts/install.js index 793ad895..50ac58fe 100644 --- a/packages/cli/scripts/install.js +++ b/packages/cli/scripts/install.js @@ -1,58 +1,35 @@ #!/usr/bin/env node +// postinstall: put a runnable relayfile binary in this package's bin/. +// +// The platform mapping, binary file names, and source-checkout detection come +// from @relayfile/sdk/relay-cli, the same module run.js and `agent-relay file` +// use, so "which binary is this host's" is decided in exactly one place. + const fs = require("fs"); const path = require("path"); -const os = require("os"); const https = require("https"); const VERSION = require("../package.json").version; const BIN_DIR = path.join(__dirname, "..", "bin"); -const PLATFORM_MAP = { - darwin: "darwin", - linux: "linux", - win32: "windows", -}; - -const ARCH_MAP = { - x64: "amd64", - arm64: "arm64", -}; - -function getBinaryFilename() { - return os.platform() === "win32" ? "relayfile.exe" : "relayfile"; -} - -function getPlatformSuffix() { - const platform = PLATFORM_MAP[os.platform()]; - const arch = ARCH_MAP[os.arch()]; +const SDK_LOAD_HINT = + "@relayfile/sdk/relay-cli could not be loaded, so the relayfile binary name for this platform " + + "cannot be resolved. Reinstall relayfile, or in a source checkout run " + + "`npm run build --workspace=packages/sdk/typescript`."; - if (!platform || !arch) { - console.error( - `Unsupported platform: ${os.platform()} ${os.arch()}` - ); +async function loadRelayCli() { + try { + return await import("@relayfile/sdk/relay-cli"); + } catch (error) { + console.error(SDK_LOAD_HINT); + console.error(error && error.message ? error.message : String(error)); process.exit(1); } - - return `${platform}-${arch}`; } -function getPackagedBinaryFilename() { - const suffix = getPlatformSuffix(); - const ext = suffix.startsWith("windows-") ? ".exe" : ""; - return `relayfile-cli-${suffix}${ext}`; -} - -function getDownloadUrl() { - return `https://github.com/AgentWorkforce/relayfile/releases/download/v${VERSION}/${getPackagedBinaryFilename()}`; -} - -function isSourceCheckout() { - const repoRoot = path.resolve(__dirname, "..", "..", ".."); - return ( - fs.existsSync(path.join(repoRoot, "go.mod")) && - fs.existsSync(path.join(repoRoot, "cmd", "relayfile-cli")) - ); +function getDownloadUrl(packagedBinaryName) { + return `https://github.com/AgentWorkforce/relayfile/releases/download/v${VERSION}/${packagedBinaryName}`; } function download(url, dest) { @@ -79,7 +56,16 @@ function download(url, dest) { } async function main() { - const binPath = path.join(BIN_DIR, getBinaryFilename()); + const { genericBinaryName, platformBinaryName, findSourceCheckoutRoot } = + await loadRelayCli(); + + const packagedBinaryName = platformBinaryName(); + if (!packagedBinaryName) { + console.error(`Unsupported platform: ${process.platform} ${process.arch}`); + process.exit(1); + } + + const binPath = path.join(BIN_DIR, genericBinaryName()); fs.mkdirSync(BIN_DIR, { recursive: true }); @@ -89,14 +75,16 @@ async function main() { return; } - if (isSourceCheckout()) { + if (findSourceCheckoutRoot(__dirname)) { + // run.js falls back to `go run ./cmd/relayfile-cli` in a checkout, so the + // command still works without a downloaded binary. console.log( "Skipping relayfile binary install in source checkout; run npm run build --workspace=packages/cli to build package binaries." ); return; } - const packagedBinPath = path.join(BIN_DIR, getPackagedBinaryFilename()); + const packagedBinPath = path.join(BIN_DIR, packagedBinaryName); if (fs.existsSync(packagedBinPath)) { fs.copyFileSync(packagedBinPath, binPath); @@ -105,7 +93,7 @@ async function main() { return; } - const url = getDownloadUrl(); + const url = getDownloadUrl(packagedBinaryName); console.log(`Downloading relayfile v${VERSION}...`); try { await download(url, binPath); diff --git a/packages/cli/scripts/run.js b/packages/cli/scripts/run.js index 4f71e0ea..e6b0e9e3 100755 --- a/packages/cli/scripts/run.js +++ b/packages/cli/scripts/run.js @@ -1,7 +1,12 @@ #!/usr/bin/env node +// The `relayfile` bin shim. It resolves the Go binary and prepares the Cloud +// session, but owns neither implementation: both live in @relayfile/sdk's +// relay-cli module, which `agent-relay file` mounts as its CLI surface. Keeping +// them there means "find the relayfile binary" and "prepare Cloud auth" exist +// once in this repo, and the two entry points cannot diverge. + const { spawnSync } = require("child_process"); -const fs = require("fs"); const os = require("os"); const path = require("path"); @@ -12,89 +17,72 @@ if (args[0] === "--version") { process.exit(0); } -const { - announceSetupIntent, - prepareCloudSession, -} = require("./cloud-preflight.js"); - -const PLATFORM_MAP = { - darwin: "darwin", - linux: "linux", - win32: "windows", -}; - -const ARCH_MAP = { - x64: "amd64", - arm64: "arm64", -}; - -function getPlatformBinaryName() { - const platform = PLATFORM_MAP[os.platform()]; - const arch = ARCH_MAP[os.arch()]; - - if (!platform || !arch) { - return null; - } - - const ext = platform === "windows" ? ".exe" : ""; - return `relayfile-cli-${platform}-${arch}${ext}`; -} - -const genericBinName = os.platform() === "win32" ? "relayfile.exe" : "relayfile"; -const packagedBinName = getPlatformBinaryName(); -const candidates = [ - path.join(__dirname, "..", "bin", genericBinName), - packagedBinName && path.join(__dirname, "..", "bin", packagedBinName), -].filter(Boolean); - -const binPath = candidates.find((candidate) => fs.existsSync(candidate)); - -// In a source checkout, postinstall intentionally skips building the -// binary. Rather than leaving the installed `relayfile` command unusable, -// fall back to running it straight from Go source. -function sourceCheckoutRoot() { - const repoRoot = path.resolve(__dirname, "..", "..", ".."); - if ( - fs.existsSync(path.join(repoRoot, "go.mod")) && - fs.existsSync(path.join(repoRoot, "cmd", "relayfile-cli")) - ) { - return repoRoot; +const SDK_BUILD_HINT = + "@relayfile/sdk/relay-cli could not be loaded. In a source checkout, build it first:\n" + + " npm run build --workspace=packages/sdk/typescript"; + +async function loadRelayCli() { + try { + return await import("@relayfile/sdk/relay-cli"); + } catch (error) { + const code = error && error.code; + if ( + code === "ERR_MODULE_NOT_FOUND" || + code === "MODULE_NOT_FOUND" || + code === "ERR_PACKAGE_PATH_NOT_EXPORTED" + ) { + console.error(SDK_BUILD_HINT); + process.exit(1); + } + throw error; } - return null; } async function main() { + const relayCli = await loadRelayCli(); + // Agent Relay's Cloud SDK owns interactive login, token refresh, locking, // and the canonical session store. The native runtime reads that same store // directly instead of receiving copied tokens or invoking agent-relay CLI. - announceSetupIntent(args, process.env); - await prepareCloudSession(args, process.env); - - let result; - if (binPath) { - result = spawnSync(binPath, args, { stdio: "inherit" }); - } else { - const repoRoot = sourceCheckoutRoot(); - if (!repoRoot) { - console.error( - `relayfile binary not found for ${os.platform()} ${os.arch()}. Reinstall the package or run postinstall again.` - ); - process.exit(1); - } - result = spawnSync("go", ["run", "./cmd/relayfile-cli", ...args], { - cwd: repoRoot, - stdio: "inherit", + relayCli.announceSetupIntent(args, process.env); + await relayCli.prepareCloudSession(args, { + env: process.env, + // This package vendors the Cloud SDK bundle; hand the resolver the exact + // path rather than making it search for it. + cloudAuthBundlePath: path.join(__dirname, "cloud-auth.cjs"), + }); + + let resolution; + try { + resolution = relayCli.resolveRelayfileBinary({ + binDirs: [path.join(__dirname, "..", "bin")], + searchFrom: [__dirname], }); - if (result.error && result.error.code === "ENOENT") { - console.error( - "relayfile binary not found and Go is not installed to run from source. " + - "Install Go or run `npm run build --workspace=packages/cli`." - ); + } catch (error) { + if (error instanceof relayCli.RelayfileBinaryNotFoundError) { + console.error(error.message); process.exit(1); } + throw error; } + // stdio is inherited rather than piped: this shim is the terminal-facing + // entry point, so relayfile's own output (including binary payloads from + // `export --output -`) must pass through untouched. + const result = spawnSync( + resolution.command, + [...resolution.args, ...args], + { + cwd: resolution.kind === "go-run" ? resolution.cwd : undefined, + stdio: "inherit", + } + ); + if (result.error) { + if (result.error.code === "ENOENT" && resolution.kind === "go-run") { + console.error(relayCli.GO_TOOLCHAIN_MISSING_MESSAGE); + process.exit(1); + } console.error(`Failed to launch relayfile: ${result.error.message}`); process.exit(1); } diff --git a/packages/cli/scripts/run.test.js b/packages/cli/scripts/run.test.js new file mode 100644 index 00000000..10b4b350 --- /dev/null +++ b/packages/cli/scripts/run.test.js @@ -0,0 +1,61 @@ +"use strict"; + +// The bin shim must keep working unchanged after binary resolution and the +// Cloud preflight moved into @relayfile/sdk/relay-cli. These tests run the +// real shim. + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const test = require("node:test"); + +const shimPath = path.join(__dirname, "run.js"); +const { version } = require("../package.json"); + +test("--version short-circuits before any binary lookup", () => { + // No binary and no Go toolchain are required for this path; it must answer + // from package.json alone. + const result = spawnSync(process.execPath, [shimPath, "--version"], { + encoding: "utf8", + env: { ...process.env, PATH: "" }, + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, `${version}\n`); +}); + +test("the shim owns no copy of binary resolution or the Cloud preflight", () => { + // Both implementations live in @relayfile/sdk/relay-cli. If either is + // reimplemented here, the two entry points can diverge. + const source = fs.readFileSync(shimPath, "utf8"); + assert.match(source, /@relayfile\/sdk\/relay-cli/); + assert.match(source, /resolveRelayfileBinary/); + assert.match(source, /prepareCloudSession/); + assert.doesNotMatch(source, /PLATFORM_MAP|ARCH_MAP/); + assert.doesNotMatch(source, /relayfile-cli-\$\{|relayfile-cli-linux/); + assert.doesNotMatch(source, /ensureCloudSession\(/); +}); + +test("the removed preflight module is not reintroduced", () => { + assert.equal(fs.existsSync(path.join(__dirname, "cloud-preflight.js")), false); +}); + +test("a real command runs through the shim and returns the binary's output", () => { + // Executes the actual Go binary (built, or via `go run` in a checkout). + const result = spawnSync(process.execPath, [shimPath, "status", "--help"], { + encoding: "utf8", + timeout: 300000, + }); + if ( + result.status !== 0 && + /Go is not installed|could not be loaded/.test( + `${result.stderr}${result.stdout}`, + ) + ) { + assert.fail( + `the shim could not reach the relayfile binary: ${result.stderr || result.stdout}`, + ); + } + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Usage: relayfile status/); +}); diff --git a/packages/sdk/typescript/CHANGELOG.md b/packages/sdk/typescript/CHANGELOG.md index 54b09bb0..854735c3 100644 --- a/packages/sdk/typescript/CHANGELOG.md +++ b/packages/sdk/typescript/CHANGELOG.md @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- New `@relayfile/sdk/relay-cli` subpath export: `createRelayCliSurface()` returns relayfile's mountable CLI surface (`id: 'relayfile'`, contract v1), which the `agent-relay` CLI mounts as `agent-relay file`. `commands` is a checked-in snapshot of the Go CLI's own command table (`relayfile __command-spec --json`, regenerated by `npm run gen:command-spec`), and `run(argv, io)` spawns the same Go binary `relayfile` does, returning its real exit code. No relayfile command is reimplemented. +- Binary resolution (`resolveRelayfileBinary`, `platformBinaryName`, `genericBinaryName`, `findSourceCheckoutRoot`) and the Cloud sign-in preflight (`prepareCloudSession`, `announceSetupIntent`) now live here, moved out of the `relayfile` package's scripts. Both the `relayfile` bin shim and `agent-relay file` use these, so each exists once in the repo. + ### Fixed - Resolve the mount binary release version from bundled package metadata so compiled Bun consumers can start the SDK without a package-tree lookup. diff --git a/packages/sdk/typescript/package.json b/packages/sdk/typescript/package.json index f358020c..9cf2d0fc 100644 --- a/packages/sdk/typescript/package.json +++ b/packages/sdk/typescript/package.json @@ -14,6 +14,10 @@ "types": "./dist/cli/index.d.ts", "default": "./dist/cli/index.js" }, + "./relay-cli": { + "types": "./dist/relay-cli/index.d.ts", + "default": "./dist/relay-cli/index.js" + }, "./cloud-login": { "types": "./dist/cloud-login.d.ts", "default": "./dist/cloud-login.js" @@ -46,7 +50,7 @@ "dist" ], "scripts": { - "build": "node scripts/sync-package-version.mjs && tsc", + "build": "node scripts/sync-package-version.mjs && tsc && node scripts/copy-relay-cli-assets.mjs", "typecheck": "tsc --noEmit", "test": "vitest run", "test:bundle:bun": "node scripts/verify-bun-workspace-mount.mjs", @@ -57,7 +61,9 @@ "test:e2e:golden-path": "node scripts/agent-workspace-golden-path-e2e.mjs", "demo:agent-workspace": "npm run build && node scripts/agent-workspace-demo.mjs", "setup:e2e": "node scripts/setup-e2e.mjs", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "gen:command-spec": "node scripts/gen-command-spec.mjs", + "check:command-spec": "node scripts/gen-command-spec.mjs --check" }, "dependencies": { "@relayfile/core": "0.10.56", @@ -71,6 +77,7 @@ "@relayfile/mount-linux-x64": "0.10.56" }, "devDependencies": { + "@agent-relay/cli-surface": "file:../../../../relay/packages/cli-surface", "typescript": "^5.7.3", "vitest": "^3.0.0" }, diff --git a/packages/sdk/typescript/scripts/copy-relay-cli-assets.mjs b/packages/sdk/typescript/scripts/copy-relay-cli-assets.mjs new file mode 100644 index 00000000..326f58d0 --- /dev/null +++ b/packages/sdk/typescript/scripts/copy-relay-cli-assets.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node +/** + * Copy src/relay-cli/command-spec.json into dist during the SDK build. + * + * The surface reads the snapshot from disk (rather than importing it) so the + * module needs no JSON import attributes and loads from both ESM and a CJS + * `import()`. tsc does not emit non-imported assets, so the build copies it. + */ + +import { copyFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const source = join(packageRoot, 'src', 'relay-cli', 'command-spec.json'); +const target = join(packageRoot, 'dist', 'relay-cli', 'command-spec.json'); + +mkdirSync(dirname(target), { recursive: true }); +copyFileSync(source, target); diff --git a/packages/sdk/typescript/scripts/gen-command-spec.mjs b/packages/sdk/typescript/scripts/gen-command-spec.mjs new file mode 100644 index 00000000..0211fb24 --- /dev/null +++ b/packages/sdk/typescript/scripts/gen-command-spec.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +/** + * Regenerate src/relay-cli/command-spec.json from the Go CLI's own command + * table by running `relayfile __command-spec --json`. + * + * The snapshot exists so `@relayfile/sdk/relay-cli` can declare `commands` + * without the binary being present at import time. It is generated, never + * hand-edited; src/relay-cli/command-spec.test.ts regenerates and diffs it so + * it cannot drift from the Go tree. + * + * Usage: + * node scripts/gen-command-spec.mjs # write the snapshot + * node scripts/gen-command-spec.mjs --check # fail if it would change + */ + +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const snapshotPath = join(packageRoot, 'src', 'relay-cli', 'command-spec.json'); + +/** + * Walk up from `start` to the relayfile source checkout root. + * + * @param {string} start - Directory to start from. + * @returns {string} The checkout root. + */ +function findCheckoutRoot(start) { + let current = start; + for (;;) { + if ( + existsSync(join(current, 'go.mod')) && + existsSync(join(current, 'cmd', 'relayfile-cli')) + ) { + return current; + } + const parent = dirname(current); + if (parent === current) { + throw new Error( + 'gen:command-spec must run inside a relayfile checkout (no go.mod + cmd/relayfile-cli found)' + ); + } + current = parent; + } +} + +/** + * Emit the command tree from the Go CLI. + * + * Prefers an already-built binary so the generator does not require a Go + * toolchain when one is present; falls back to `go run`. + * + * @returns {string} The emitted JSON. + */ +export function emitCommandSpec() { + const checkoutRoot = findCheckoutRoot(packageRoot); + const builtBinaries = [ + join(checkoutRoot, 'packages', 'cli', 'bin', 'relayfile'), + join(checkoutRoot, 'relayfile-cli'), + ]; + const built = builtBinaries.find((candidate) => existsSync(candidate)); + + const [command, args] = built + ? [built, ['__command-spec', '--json']] + : ['go', ['run', './cmd/relayfile-cli', '__command-spec', '--json']]; + + const result = spawnSync(command, args, { + cwd: checkoutRoot, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }); + + if (result.error) { + if (result.error.code === 'ENOENT' && !built) { + throw new Error( + 'gen:command-spec needs either a built relayfile binary ' + + '(npm run build --workspace=packages/cli) or a Go toolchain on PATH.' + ); + } + throw result.error; + } + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(' ')} exited ${result.status}\n${result.stderr ?? ''}` + ); + } + + // Normalize to exactly one trailing newline so the snapshot is byte-stable + // regardless of which emitter produced it. + return `${result.stdout.trimEnd()}\n`; +} + +/** + * CLI entry point. Guarded so the module can be imported for `emitCommandSpec` + * without generating or exiting. + */ +function main() { + const checkOnly = process.argv.includes('--check'); + const emitted = emitCommandSpec(); + const current = existsSync(snapshotPath) ? readFileSync(snapshotPath, 'utf8') : ''; + + if (emitted === current) { + if (!checkOnly) { + process.stdout.write('command-spec.json is up to date\n'); + } + return 0; + } + + if (checkOnly) { + process.stderr.write( + 'command-spec.json is out of date with the Go command tree.\n' + + 'Run: npm run gen:command-spec --workspace=packages/sdk/typescript\n' + ); + return 1; + } + + writeFileSync(snapshotPath, emitted, 'utf8'); + process.stdout.write(`wrote ${snapshotPath}\n`); + return 0; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exit(main()); +} diff --git a/packages/sdk/typescript/src/import-safety.test.ts b/packages/sdk/typescript/src/import-safety.test.ts index 6133b499..1f7a1e1c 100644 --- a/packages/sdk/typescript/src/import-safety.test.ts +++ b/packages/sdk/typescript/src/import-safety.test.ts @@ -24,6 +24,11 @@ describe("default entry import safety", () => { expect([...graph.files].sort()).not.toContain( path.join(SDK_SRC_ROOT, "cloud-login.ts") ) + // The relay-cli surface spawns the Go binary, so it must stay a subpath + // export: the host CLI imports it only when `agent-relay file` is invoked. + expect([...graph.files].sort()).not.toContain( + path.join(SDK_SRC_ROOT, "relay-cli", "index.ts") + ) expect([...graph.nodeSpecifiers].sort()).toEqual([]) }) }) diff --git a/packages/sdk/typescript/src/relay-cli/cloud-preflight.test.ts b/packages/sdk/typescript/src/relay-cli/cloud-preflight.test.ts new file mode 100644 index 00000000..2dd3b314 --- /dev/null +++ b/packages/sdk/typescript/src/relay-cli/cloud-preflight.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from "vitest" + +import { + SETUP_INTENT, + SETUP_INTENT_PRINTED_ENV, + announceSetupIntent, + hasValidSetupArguments, + parseGoDurationMilliseconds, + prepareCloudSession, + shouldPrepareCloudSession, + type EnsureCloudSessionOptions +} from "./cloud-preflight.js" + +/** + * Ported from packages/cli/scripts/cloud-preflight.test.js when the preflight + * moved into the SDK so both `relayfile` and `agent-relay file` run the same + * one. The bundled Cloud SDK itself is still tested where it is vendored, in + * packages/cli/scripts/cloud-auth.test.js. + */ + +describe("announceSetupIntent", () => { + it("announces the setup intent once before authentication", () => { + const env: NodeJS.ProcessEnv = {} + const lines: string[] = [] + expect(announceSetupIntent([], env, (line) => lines.push(line))).toBe(true) + expect(announceSetupIntent([], env, (line) => lines.push(line))).toBe(true) + expect(lines).toEqual([SETUP_INTENT]) + expect(env[SETUP_INTENT_PRINTED_ENV]).toBe("1") + }) + + it("says nothing for an invocation that needs no session", () => { + const lines: string[] = [] + expect(announceSetupIntent(["status"], {}, (line) => lines.push(line))).toBe(false) + expect(lines).toEqual([]) + }) +}) + +describe("prepareCloudSession", () => { + it("prepares Cloud auth through the Agent Relay SDK for a bare invocation", async () => { + const env: NodeJS.ProcessEnv = {} + const calls: EnsureCloudSessionOptions[] = [] + const prepared = await prepareCloudSession([], { + env, + ensureCloudSession: async (options) => { + calls.push(options) + return { + auth: { + apiUrl: "https://cloud.example", + accessToken: "cld_at_test_secret", + refreshToken: "cld_rt_test_secret" + } + } + } + }) + + expect(prepared).toBe(true) + expect(calls[0]!.signal).toBeInstanceOf(AbortSignal) + expect(calls[0]!.signal.aborted).toBe(false) + expect(calls).toEqual([ + { + apiUrl: "https://agentrelay.com/cloud", + client: "relayfile", + interactive: true, + device: false, + loginTimeoutMs: 300000, + refreshTimeoutMs: 10000, + signal: calls[0]!.signal + } + ]) + // Credentials stay in the SDK's canonical store; nothing is promoted into + // the environment for the child process. + expect(env).toEqual({}) + }) + + it("forwards setup's Cloud URL and no-open mode to the SDK", async () => { + let received: EnsureCloudSessionOptions | undefined + await prepareCloudSession( + [ + "setup", + "--cloud-api-url=https://staging.example/cloud", + "--no-open", + "--login-timeout=10s" + ], + { + env: {}, + ensureCloudSession: async (options) => { + received = options + return { auth: { apiUrl: options.apiUrl } } + } + } + ) + + expect(received).toEqual({ + apiUrl: "https://staging.example/cloud", + client: "relayfile", + interactive: true, + device: true, + loginTimeoutMs: 10000, + refreshTimeoutMs: 10000, + signal: received!.signal + }) + expect(received!.signal).toBeInstanceOf(AbortSignal) + }) + + it("reads the Cloud URL from the environment when no flag is given", async () => { + let received: EnsureCloudSessionOptions | undefined + await prepareCloudSession(["setup"], { + env: { RELAYFILE_CLOUD_API_URL: "https://env.example/cloud" }, + ensureCloudSession: async (options) => { + received = options + } + }) + expect(received!.apiUrl).toBe("https://env.example/cloud") + }) + + it("keeps browser login enabled for false no-open values", async () => { + for (const value of ["false", "0"]) { + let received: EnsureCloudSessionOptions | undefined + await prepareCloudSession(["setup", `--no-open=${value}`], { + env: {}, + ensureCloudSession: async (options) => { + received = options + } + }) + expect(received!.device).toBe(false) + } + }) + + it("bounds authentication by the login timeout and aborts late writes", async () => { + let receivedSignal: AbortSignal | undefined + let lateCredentialWrite = false + await expect( + prepareCloudSession(["setup", "--login-timeout=1ms"], { + env: {}, + ensureCloudSession: ({ signal }) => { + receivedSignal = signal + return new Promise((resolve, reject) => { + const lateWrite = setTimeout(() => { + lateCredentialWrite = true + resolve() + }, 25) + signal.addEventListener( + "abort", + () => { + clearTimeout(lateWrite) + reject(signal.reason) + }, + { once: true } + ) + }) + } + }) + ).rejects.toThrow(/Cloud sign-in timed out after 1ms/) + + expect(receivedSignal!.aborted).toBe(true) + expect((receivedSignal!.reason as Error).message).toMatch(/timed out after 1ms/) + await new Promise((resolve) => setTimeout(resolve, 30)) + expect(lateCredentialWrite).toBe(false) + }) + + it("never starts auth for pseudo-help values", async () => { + for (const args of [ + ["setup", "--help=false"], + ["setup", "-h=0", "--cloud-token="] + ]) { + let calls = 0 + const prepared = await prepareCloudSession(args, { + env: { RELAYFILE_CLOUD_TOKEN: "inherited-token" }, + ensureCloudSession: async () => { + calls += 1 + } + }) + expect(prepared).toBe(false) + expect(calls).toBe(0) + } + }) + + it("short-circuits native help even in a setup value slot", async () => { + let calls = 0 + const prepared = await prepareCloudSession(["setup", "--provider", "--help"], { + env: {}, + ensureCloudSession: async () => { + calls += 1 + } + }) + expect(prepared).toBe(false) + expect(calls).toBe(0) + }) + + it("treats --version as a native version only when the CLI would", async () => { + expect(shouldPrepareCloudSession(["--version"], {})).toBe(false) + expect(shouldPrepareCloudSession(["version"], {})).toBe(false) + expect(shouldPrepareCloudSession(["setup", "--local-dir", "--version"], {})).toBe(true) + + let calls = 0 + const prepared = await prepareCloudSession(["setup", "--local-dir", "--version"], { + env: {}, + ensureCloudSession: async () => { + calls += 1 + } + }) + expect(prepared).toBe(true) + expect(calls).toBe(1) + }) + + it("follows the native setup grammar for dash-prefixed values", async () => { + const args = ["setup", "--local-dir", "-mirror"] + expect(hasValidSetupArguments(args)).toBe(true) + let calls = 0 + const prepared = await prepareCloudSession(args, { + env: {}, + ensureCloudSession: async () => { + calls += 1 + } + }) + expect(prepared).toBe(true) + expect(calls).toBe(1) + }) + + it("rejects malformed setup arguments before interactive auth", async () => { + expect(hasValidSetupArguments(["setup", "--provider"])).toBe(false) + expect(hasValidSetupArguments(["setup", "--unknown"])).toBe(false) + expect(hasValidSetupArguments(["setup", "unexpected"])).toBe(false) + expect(shouldPrepareCloudSession(["setup", "--provider"], {})).toBe(false) + expect(shouldPrepareCloudSession(["setup", "--unknown"], {})).toBe(false) + + let authCalls = 0 + await expect( + prepareCloudSession(["setup", "--provider"], { + env: {}, + ensureCloudSession: async () => { + authCalls += 1 + throw new Error("interactive auth must not run") + } + }) + ).rejects.toThrow(/--provider requires a value/) + expect(authCalls).toBe(0) + }) + + it("rejects invalid setup values before interactive auth", async () => { + expect(hasValidSetupArguments(["setup", "--connect-timeout=bogus"])).toBe(false) + expect(hasValidSetupArguments(["setup", "--backend", "invalid"])).toBe(false) + + let authCalls = 0 + await expect( + prepareCloudSession(["setup", "--backend", "invalid"], { + env: {}, + ensureCloudSession: async () => { + authCalls += 1 + } + }) + ).rejects.toThrow(/unsupported integration backend/) + expect(authCalls).toBe(0) + }) +}) + +describe("shouldPrepareCloudSession", () => { + it("leaves help and caller-owned tokens alone", () => { + expect(shouldPrepareCloudSession(["setup", "--help"], {})).toBe(false) + expect(shouldPrepareCloudSession(["setup", "--help=true"], {})).toBe(false) + expect(shouldPrepareCloudSession(["setup", "--help=false"], {})).toBe(false) + expect(shouldPrepareCloudSession(["setup", "-h=0"], {})).toBe(false) + expect(shouldPrepareCloudSession(["setup", "--cloud-token", "explicit"], {})).toBe(false) + expect(shouldPrepareCloudSession(["setup", "-cloud-token", "explicit"], {})).toBe(false) + expect(shouldPrepareCloudSession(["setup", "--cloud-token="], {})).toBe(true) + expect(shouldPrepareCloudSession(["setup", "--cloud-token", ""], {})).toBe(true) + expect( + shouldPrepareCloudSession(["setup", "--cloud-token="], { + RELAYFILE_CLOUD_TOKEN: "inherited-token" + }) + ).toBe(true) + expect( + shouldPrepareCloudSession(["setup"], { RELAYFILE_CLOUD_TOKEN: "inherited-token" }) + ).toBe(false) + expect(shouldPrepareCloudSession([], { CLOUD_API_ACCESS_TOKEN: "ci-token" })).toBe(false) + expect(shouldPrepareCloudSession(["status"], {})).toBe(false) + }) + + it("accepts valid explicit setup arguments", () => { + expect( + hasValidSetupArguments([ + "setup", + "--provider", + "github", + "--workspace=frontend", + "--once", + "--no-open=true" + ]) + ).toBe(true) + expect( + shouldPrepareCloudSession( + ["setup", "--provider", "github", "--workspace=frontend", "--once"], + {} + ) + ).toBe(true) + }) + + it("never prepares a session for a non-setup command", () => { + for (const command of ["status", "mount", "workspace", "tree", "logs"]) { + expect(shouldPrepareCloudSession([command], {})).toBe(false) + } + }) +}) + +describe("parseGoDurationMilliseconds", () => { + it("validates and converts Go durations for SDK login", () => { + expect(parseGoDurationMilliseconds("10s")).toBe(10000) + expect(parseGoDurationMilliseconds("1m30.5s")).toBe(90500) + expect(parseGoDurationMilliseconds("250ms")).toBe(250) + expect(parseGoDurationMilliseconds("0")).toBe(0) + expect(parseGoDurationMilliseconds("bogus")).toBeNull() + expect(parseGoDurationMilliseconds("10")).toBeNull() + expect(parseGoDurationMilliseconds("")).toBeNull() + }) +}) diff --git a/packages/sdk/typescript/src/relay-cli/cloud-preflight.ts b/packages/sdk/typescript/src/relay-cli/cloud-preflight.ts new file mode 100644 index 00000000..159caaf8 --- /dev/null +++ b/packages/sdk/typescript/src/relay-cli/cloud-preflight.ts @@ -0,0 +1,429 @@ +/** + * The one implementation of relayfile's Cloud sign-in preflight. + * + * Agent Relay's Cloud SDK owns interactive login, token refresh, locking, and + * the canonical session store. The native runtime reads that same store + * directly instead of receiving copied tokens, so this preflight only has to + * ensure a session exists before `relayfile setup` starts. + * + * Both entry points into the Go binary run it: the `relayfile` bin shim + * (`packages/cli/scripts/run.js`) and the `agent-relay file` CLI surface, so + * `agent-relay file setup` behaves identically to `relayfile setup`. + */ + +import { createRequire } from "node:module" +import { existsSync } from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +export const DEFAULT_CLOUD_API_URL = "https://agentrelay.com/cloud" +export const SETUP_INTENT = + "Relayfile setup. This signs you in, connects an integration, and prepares a local VFS mount." +export const SETUP_INTENT_PRINTED_ENV = "RELAYFILE_NPM_SETUP_INTENT_PRINTED" + +const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1000 +const DEFAULT_REFRESH_TIMEOUT_MS = 10 * 1000 +const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647 +const MAX_GO_DURATION_NANOSECONDS = 9_223_372_036_854_775_807 + +/** Flags `relayfile setup` accepts, mapped to whether they take a value. */ +const SETUP_FLAGS = new Map([ + ["cloud-api-url", true], + ["cloud-token", true], + ["workspace", true], + ["provider", true], + ["backend", true], + ["local-dir", true], + ["no-open", false], + ["skip-mount", false], + ["once", false], + ["login-timeout", true], + ["connect-timeout", true], + ["help", false], + ["h", false] +]) + +const GO_BOOLEAN_VALUES = new Set([ + "1", + "t", + "T", + "true", + "TRUE", + "True", + "0", + "f", + "F", + "false", + "FALSE", + "False" +]) +const GO_TRUE_VALUES = new Set(["1", "t", "T", "true", "TRUE", "True"]) +const GO_DURATION_UNITS_IN_NANOSECONDS = new Map([ + ["ns", 1], + ["us", 1_000], + ["µs", 1_000], + ["μs", 1_000], + ["ms", 1_000_000], + ["s", 1_000_000_000], + ["m", 60 * 1_000_000_000], + ["h", 60 * 60 * 1_000_000_000] +]) +const VALID_INTEGRATION_BACKENDS = new Set(["", "default", "nango", "composio"]) + +export type SetupFlagValue = string | boolean + +export type ParsedSetupArguments = + | { valid: true; values: Map; durations: Map } + | { valid: false; error: string } + +export interface EnsureCloudSessionOptions { + apiUrl: string + client: string + interactive: boolean + device: boolean + loginTimeoutMs: number + refreshTimeoutMs: number + signal: AbortSignal +} + +export type EnsureCloudSession = (options: EnsureCloudSessionOptions) => Promise + +export interface CloudPreflightOptions { + /** Defaults to `process.env`. Mutated to record the printed intent. */ + env?: NodeJS.ProcessEnv + /** Where the setup intent line is written. Defaults to `console.log`. */ + writeLine?: (line: string) => void + /** Injected Cloud SDK entry point; loaded from the vendored bundle by default. */ + ensureCloudSession?: EnsureCloudSession + /** Explicit path to the vendored `cloud-auth.cjs` bundle. */ + cloudAuthBundlePath?: string +} + +/** + * Parse a Go `time.Duration` string the way the Go CLI does. + * + * @param value - A duration such as `5m` or `1h30m`. + * @returns Milliseconds, or null when the string is not a valid duration. + */ +export function parseGoDurationMilliseconds(value: string): number | null { + let remaining = String(value) + let sign = 1 + if (remaining.startsWith("+") || remaining.startsWith("-")) { + sign = remaining[0] === "-" ? -1 : 1 + remaining = remaining.slice(1) + } + if (remaining === "0") { + return 0 + } + if (!remaining) { + return null + } + + let nanoseconds = 0 + let parts = 0 + while (remaining) { + const match = /^(\d+(?:\.\d*)?|\.\d+)(ns|us|µs|μs|ms|s|m|h)/.exec(remaining) + if (!match) { + return null + } + const amount = Number(match[1]) + const unitNanoseconds = GO_DURATION_UNITS_IN_NANOSECONDS.get(match[2]!)! + nanoseconds += amount * unitNanoseconds + if (!Number.isFinite(nanoseconds) || nanoseconds > MAX_GO_DURATION_NANOSECONDS) { + return null + } + remaining = remaining.slice(match[0].length) + parts += 1 + } + return parts > 0 ? (sign * nanoseconds) / 1_000_000 : null +} + +function wantsNativeVersion(args: readonly string[]): boolean { + return args.length === 1 && (args[0] === "--version" || args[0] === "version") +} + +function wantsNativeHelp(args: readonly string[]): boolean { + return args.some((arg) => arg === "--help" || arg === "-h") +} + +/** + * Parse `relayfile setup`'s arguments without running it. + * + * @param args - Argv, with or without a leading `setup`. + * @returns The parsed flags, or the first validation error. + */ +export function parseSetupArguments(args: readonly string[]): ParsedSetupArguments { + const setupArgs = args[0] === "setup" ? args.slice(1) : args + const values = new Map() + const durations = new Map() + for (let index = 0; index < setupArgs.length; index += 1) { + const arg = setupArgs[index]! + if (arg === "--") { + if (index !== setupArgs.length - 1) { + return { valid: false, error: "setup does not accept positional arguments" } + } + break + } + + const match = /^--?([^=]+)(?:=(.*))?$/.exec(arg) + if (!match) { + return { valid: false, error: `unexpected setup argument ${JSON.stringify(arg)}` } + } + const name = match[1]! + const inlineValue = match[2] + const takesValue = SETUP_FLAGS.get(name) + if (takesValue === undefined) { + return { valid: false, error: `unknown setup flag --${name}` } + } + if (takesValue) { + let value = inlineValue + if (inlineValue === undefined) { + const next = setupArgs[index + 1] + if (next === undefined) { + return { valid: false, error: `--${name} requires a value` } + } + value = next + index += 1 + } + values.set(name, value!) + if (name === "login-timeout" || name === "connect-timeout") { + const duration = parseGoDurationMilliseconds(value!) + if (duration === null) { + return { + valid: false, + error: `--${name} has invalid duration ${JSON.stringify(value)}` + } + } + if (name === "login-timeout" && duration <= 0) { + return { valid: false, error: "--login-timeout must be greater than zero" } + } + durations.set(name, duration) + } + continue + } + if (inlineValue !== undefined && !GO_BOOLEAN_VALUES.has(inlineValue)) { + return { + valid: false, + error: `--${name} has invalid boolean value ${JSON.stringify(inlineValue)}` + } + } + values.set(name, inlineValue === undefined || GO_TRUE_VALUES.has(inlineValue)) + } + + const backend = String(values.get("backend") || "") + .trim() + .toLowerCase() + if (!VALID_INTEGRATION_BACKENDS.has(backend)) { + return { + valid: false, + error: `unsupported integration backend ${JSON.stringify( + backend + )} (expected nango or composio)` + } + } + + return { valid: true, values, durations } +} + +/** + * Report whether the given argv would parse as a valid `setup` invocation. + * + * @param args - Argv, with or without a leading `setup`. + * @returns True when the arguments are valid. + */ +export function hasValidSetupArguments(args: readonly string[]): boolean { + return parseSetupArguments(args).valid +} + +/** + * Decide whether a Cloud session must be prepared before the binary runs. + * + * @param args - Argv passed to relayfile. + * @param env - Environment to read caller-owned credentials from. + * @returns True only for a real interactive `setup` with no explicit credentials. + */ +export function shouldPrepareCloudSession( + args: readonly string[], + env: NodeJS.ProcessEnv +): boolean { + const setupCommand = args.length === 0 || args[0] === "setup" + if (!setupCommand) { + return false + } + if (wantsNativeVersion(args) || wantsNativeHelp(args)) { + return false + } + const parsed = parseSetupArguments(args) + if (!parsed.valid) { + return false + } + if (parsed.values.has("help") || parsed.values.has("h")) { + return false + } + // Explicit credentials are caller-owned. Let the Go CLI validate and use + // them without replacing them with an interactive session. + const relayfileCloudToken = parsed.values.has("cloud-token") + ? String(parsed.values.get("cloud-token") || "").trim() + : String(env.RELAYFILE_CLOUD_TOKEN || "").trim() + if (relayfileCloudToken || String(env.CLOUD_API_ACCESS_TOKEN || "").trim()) { + return false + } + // Let the native CLI report malformed flags without first opening a login + // flow or mutating the caller's canonical Cloud session. + return true +} + +/** + * Print the one-time setup intent line, if this invocation warrants it. + * + * @param args - Argv passed to relayfile. + * @param env - Environment; the printed marker is recorded here. + * @param writeLine - Sink for the line. Defaults to `console.log`. + * @returns True when this invocation prepares a Cloud session. + */ +export function announceSetupIntent( + args: readonly string[], + env: NodeJS.ProcessEnv, + writeLine: (line: string) => void = console.log +): boolean { + if (!shouldPrepareCloudSession(args, env)) { + return false + } + if (String(env[SETUP_INTENT_PRINTED_ENV] || "").trim() !== "1") { + writeLine(SETUP_INTENT) + env[SETUP_INTENT_PRINTED_ENV] = "1" + } + return true +} + +function bundleCandidates(explicitPath?: string): string[] { + const candidates: string[] = [] + if (explicitPath) { + candidates.push(explicitPath) + } + try { + const require = createRequire(import.meta.url) + candidates.push(require.resolve("relayfile/scripts/cloud-auth.cjs")) + } catch { + // The CLI package is not installed alongside the SDK; fall through to the + // workspace layout below. + } + let current = path.dirname(fileURLToPath(import.meta.url)) + for (;;) { + candidates.push( + path.join(current, "packages", "cli", "scripts", "cloud-auth.cjs") + ) + const parent = path.dirname(current) + if (parent === current) { + break + } + current = parent + } + return candidates +} + +/** + * Load `ensureCloudSession` from the vendored Agent Relay Cloud SDK bundle. + * + * @param explicitPath - A known bundle path, preferred when supplied. + * @returns The Cloud SDK's session entry point. + * @throws When the bundle cannot be found or loaded. + */ +export function loadCloudSessionSDK(explicitPath?: string): EnsureCloudSession { + const require = createRequire(import.meta.url) + const candidates = bundleCandidates(explicitPath) + for (const candidate of candidates) { + if (!existsSync(candidate)) { + continue + } + try { + return require(candidate).ensureCloudSession as EnsureCloudSession + } catch (error) { + throw new Error( + "Relayfile's Agent Relay Cloud SDK bundle failed to load. Reinstall relayfile or run its package build.", + { cause: error } + ) + } + } + throw new Error( + "Relayfile's Agent Relay Cloud SDK bundle is missing. Reinstall relayfile or run its package build." + ) +} + +/** + * Ensure a Cloud session exists before `relayfile setup` starts. + * + * @param args - Argv passed to relayfile. + * @param options - Environment, output sink, and injectable Cloud SDK. + * @returns True when a session was prepared, false when the invocation needs none. + * @throws When setup's arguments are invalid, or sign-in fails or times out. + */ +export async function prepareCloudSession( + args: readonly string[], + options: CloudPreflightOptions = {} +): Promise { + const env = options.env ?? process.env + const setupCommand = args.length === 0 || args[0] === "setup" + if (wantsNativeVersion(args) || wantsNativeHelp(args)) { + return false + } + const parsed = setupCommand ? parseSetupArguments(args) : null + if (parsed !== null && parsed.valid === false) { + throw new Error(parsed.error) + } + if (!shouldPrepareCloudSession(args, env)) { + return false + } + // shouldPrepareCloudSession only returns true for a setup invocation, so a + // valid parse is guaranteed here. + const setup = parsed as Extract + + const ensureCloudSession = + options.ensureCloudSession ?? loadCloudSessionSDK(options.cloudAuthBundlePath) + const apiUrl = + String(setup.values.get("cloud-api-url") || "").trim() || + String(env.RELAYFILE_CLOUD_API_URL || "").trim() || + String(env.CLOUD_API_URL || "").trim() || + DEFAULT_CLOUD_API_URL + const loginTimeoutMs = setup.durations.get("login-timeout") || DEFAULT_LOGIN_TIMEOUT_MS + const loginAbort = new AbortController() + let timer: ReturnType | undefined + try { + await Promise.race([ + ensureCloudSession({ + apiUrl, + client: "relayfile", + interactive: true, + device: setup.values.get("no-open") === true, + loginTimeoutMs, + refreshTimeoutMs: Math.max( + 1, + Math.min(loginTimeoutMs, DEFAULT_REFRESH_TIMEOUT_MS) + ), + signal: loginAbort.signal + }), + new Promise((_, reject) => { + timer = setTimeout( + () => { + const error = new Error( + `Cloud sign-in timed out after ${setup.values.get("login-timeout") || "5m"}` + ) + loginAbort.abort(error) + reject(error) + }, + Math.min(loginTimeoutMs, MAX_NODE_TIMER_DELAY_MS) + ) + }) + ]) + } finally { + clearTimeout(timer) + } + + // The SDK owns and refreshes its canonical on-disk session. Do not promote + // that session into CLOUD_API_* for the child: Relayfile would correctly + // treat those variables as caller-owned and would not persist rotated + // refresh tokens back to the shared file. The native runtime reads the same + // canonical file directly. Genuine caller-provided environment credentials + // bypass this preflight above and remain untouched. + return true +} diff --git a/packages/sdk/typescript/src/relay-cli/command-spec.json b/packages/sdk/typescript/src/relay-cli/command-spec.json new file mode 100644 index 00000000..b2baa5d8 --- /dev/null +++ b/packages/sdk/typescript/src/relay-cli/command-spec.json @@ -0,0 +1,1636 @@ +[ + { + "name": "setup", + "description": "Sign in, connect an integration, and mount the workspace", + "options": [ + { + "flags": "--cloud-api-url ", + "description": "Relayfile Cloud API URL (default: $RELAYFILE_CLOUD_API_URL or https://agentrelay.com/cloud)" + }, + { + "flags": "--cloud-token ", + "description": "Relayfile Cloud access token; skips browser login when set" + }, + { + "flags": "--workspace ", + "description": "workspace name to create" + }, + { + "flags": "--provider ", + "description": "integration provider to connect; use none to skip" + }, + { + "flags": "--backend ", + "description": "integration backend to request (nango or composio)" + }, + { + "flags": "--local-dir ", + "description": "local mount directory" + }, + { + "flags": "--no-open", + "description": "print browser URLs instead of opening them", + "defaultValue": false + }, + { + "flags": "--skip-mount", + "description": "finish after setup without starting the mount process", + "defaultValue": false + }, + { + "flags": "--once", + "description": "run one mount sync cycle and exit", + "defaultValue": false + }, + { + "flags": "--login-timeout ", + "description": "cloud login timeout", + "defaultValue": "5m0s" + }, + { + "flags": "--connect-timeout ", + "description": "integration connection timeout", + "defaultValue": "5m0s" + } + ] + }, + { + "name": "login", + "description": "Sign in via agent-relay cloud login (or --api-key for self-hosted)", + "options": [ + { + "flags": "--server ", + "description": "relayfile server URL (only used with --api-key)" + }, + { + "flags": "--token ", + "description": "relayfile API token" + }, + { + "flags": "--cloud-api-url ", + "description": "Relayfile Cloud API URL (default: $RELAYFILE_CLOUD_API_URL or https://agentrelay.com/cloud)" + }, + { + "flags": "--cloud-token ", + "description": "Relayfile Cloud access token; skips browser login when set" + }, + { + "flags": "--api-key", + "description": "use the legacy API-key flow against --server instead of the cloud browser login", + "defaultValue": false + }, + { + "flags": "--no-open", + "description": "print the cloud sign-in URL instead of opening it", + "defaultValue": false + }, + { + "flags": "--login-timeout ", + "description": "cloud login timeout", + "defaultValue": "5m0s" + }, + { + "flags": "--workspace ", + "description": "workspace name or id to refresh; defaults to the active workspace" + }, + { + "flags": "--skip-workspace-refresh", + "description": "sign into the cloud only; do not refresh the workspace token", + "defaultValue": false + }, + { + "flags": "--provision-messaging-only", + "description": "create a separate Relayfile-backed workspace when the active Agent Relay workspace is messaging-only", + "defaultValue": false + } + ] + }, + { + "name": "logout", + "description": "Clear Relayfile credentials from this machine" + }, + { + "name": "workspace", + "description": "Create, join, select via agent-relay, list, show current, or delete locally tracked workspaces", + "subcommands": [ + { + "name": "create", + "description": "Create a workspace on the relayfile server", + "args": [ + { + "name": "name", + "description": "workspace name", + "required": true + } + ], + "options": [ + { + "flags": "--token ", + "description": "relayfile token override" + } + ] + }, + { + "name": "join", + "description": "Join an existing workspace by id and track it locally", + "args": [ + { + "name": "workspace-id", + "description": "workspace id to join", + "required": true + } + ], + "options": [ + { + "flags": "--cloud-api-url ", + "description": "Relayfile Cloud API URL (default: $RELAYFILE_CLOUD_API_URL or https://agentrelay.com/cloud)" + }, + { + "flags": "--cloud-token ", + "description": "Relayfile Cloud access token; skips browser login when set" + }, + { + "flags": "--name ", + "description": "local workspace name" + }, + { + "flags": "--write", + "description": "request read/write workspace token scopes", + "defaultValue": false + }, + { + "flags": "--no-open", + "description": "print browser URLs instead of opening them", + "defaultValue": false + }, + { + "flags": "--login-timeout ", + "description": "cloud login timeout", + "defaultValue": "5m0s" + } + ] + }, + { + "name": "use", + "description": "Select the active workspace for later commands", + "args": [ + { + "name": "name", + "description": "workspace name or id", + "required": true + } + ] + }, + { + "name": "list", + "description": "List locally tracked workspaces", + "options": [ + { + "flags": "--server ", + "description": "relayfile server URL override" + }, + { + "flags": "--token ", + "description": "relayfile token override" + }, + { + "flags": "--names-only", + "description": "print bare workspace names without an active marker", + "defaultValue": false + } + ] + }, + { + "name": "current", + "description": "Show the active workspace", + "options": [ + { + "flags": "--token ", + "description": "relayfile token override" + }, + { + "flags": "--verbose", + "description": "include workspace id and selection source", + "defaultValue": false + } + ] + }, + { + "name": "view", + "description": "Manage read-only aliases into a registered workspace mirror", + "subcommands": [ + { + "name": "add", + "description": "Create an alias directory pointing into the canonical mirror", + "args": [ + { + "name": "remote-path", + "description": "remote path to expose", + "required": true + }, + { + "name": "local-dir", + "description": "local alias directory", + "required": true + } + ], + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--replace", + "description": "replace an existing relayfile view symlink", + "defaultValue": false + } + ] + }, + { + "name": "list", + "description": "List alias directories registered for a workspace", + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--json", + "description": "emit JSON", + "defaultValue": false + } + ] + }, + { + "name": "remove", + "description": "Remove an alias directory", + "args": [ + { + "name": "local-dir", + "description": "local alias directory", + "required": true + } + ], + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + } + ] + } + ] + }, + { + "name": "status", + "description": "Show sync status for a workspace", + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--json", + "description": "emit JSON", + "defaultValue": false + } + ] + }, + { + "name": "delete", + "description": "Delete a locally tracked workspace", + "args": [ + { + "name": "name", + "description": "workspace name or id", + "required": true + } + ], + "options": [ + { + "flags": "--yes", + "description": "skip confirmation prompt", + "defaultValue": false + } + ] + } + ] + }, + { + "name": "integration", + "description": "Connect, discover, list, disconnect, or adopt workspace integrations", + "subcommands": [ + { + "name": "connect", + "description": "Connect a provider integration to a workspace", + "args": [ + { + "name": "provider", + "description": "provider id, e.g. github or linear", + "required": true + } + ], + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--cloud-api-url ", + "description": "Relayfile Cloud API URL (default: $RELAYFILE_CLOUD_API_URL or https://agentrelay.com/cloud)" + }, + { + "flags": "--backend ", + "description": "integration backend to request (nango or composio)" + }, + { + "flags": "--no-open", + "description": "print the hosted URL instead of opening it", + "defaultValue": false + }, + { + "flags": "--timeout ", + "description": "integration readiness timeout", + "defaultValue": "5m0s" + }, + { + "flags": "--wait-sync", + "description": "wait for initial sync before returning", + "defaultValue": false + } + ] + }, + { + "name": "available", + "description": "List providers available to connect", + "aliases": [ + "catalog", + "providers" + ], + "options": [ + { + "flags": "--cloud-api-url ", + "description": "Relayfile Cloud API URL (default: $RELAYFILE_CLOUD_API_URL or https://agentrelay.com/cloud)" + }, + { + "flags": "--backend ", + "description": "filter by backend (nango or composio)" + }, + { + "flags": "--search ", + "description": "search provider id, display name, category, or backend" + }, + { + "flags": "--json", + "description": "emit JSON", + "defaultValue": false + }, + { + "flags": "--refresh", + "description": "refresh the cached provider catalog", + "defaultValue": false + } + ] + }, + { + "name": "search", + "description": "Search the provider catalog", + "args": [ + { + "name": "query", + "description": "search query", + "required": true + } + ], + "options": [ + { + "flags": "--cloud-api-url ", + "description": "Relayfile Cloud API URL (default: $RELAYFILE_CLOUD_API_URL or https://agentrelay.com/cloud)" + }, + { + "flags": "--backend ", + "description": "filter by backend (nango or composio)" + }, + { + "flags": "--json", + "description": "emit JSON", + "defaultValue": false + }, + { + "flags": "--refresh", + "description": "refresh the cached provider catalog", + "defaultValue": false + } + ] + }, + { + "name": "list", + "description": "List a workspace's connected integrations", + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--json", + "description": "emit JSON", + "defaultValue": false + }, + { + "flags": "--cloud-api-url ", + "description": "Relayfile Cloud API URL (default: $RELAYFILE_CLOUD_API_URL or https://agentrelay.com/cloud)" + }, + { + "flags": "--cloud-token ", + "description": "Relayfile Cloud access token" + } + ] + }, + { + "name": "disconnect", + "description": "Disconnect a provider integration", + "args": [ + { + "name": "provider", + "description": "provider id", + "required": true + } + ], + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--cloud-api-url ", + "description": "Relayfile Cloud API URL (default: $RELAYFILE_CLOUD_API_URL or https://agentrelay.com/cloud)" + }, + { + "flags": "--yes", + "description": "skip confirmation", + "defaultValue": false + } + ] + }, + { + "name": "adopt", + "description": "Adopt an existing Nango connection as a workspace integration", + "args": [ + { + "name": "provider", + "description": "provider id", + "required": true + } + ], + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--cloud-api-url ", + "description": "Relayfile Cloud API URL (default: $RELAYFILE_CLOUD_API_URL or https://agentrelay.com/cloud)" + }, + { + "flags": "--connection-id ", + "description": "Nango connection id to adopt (required)" + }, + { + "flags": "--provider-config-key ", + "description": "optional Nango providerConfigKey override" + }, + { + "flags": "--yes", + "description": "skip confirmation", + "defaultValue": false + } + ] + }, + { + "name": "set-metadata", + "description": "Set provider connection metadata as KEY=VALUE pairs", + "args": [ + { + "name": "provider", + "description": "provider id", + "required": true + }, + { + "name": "assignments", + "description": "one or more KEY=VALUE metadata assignments", + "required": true, + "variadic": true + } + ], + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--cloud-api-url ", + "description": "Relayfile Cloud API URL (default: $RELAYFILE_CLOUD_API_URL or https://agentrelay.com/cloud)" + }, + { + "flags": "--yes", + "description": "skip confirmation", + "defaultValue": false + } + ] + }, + { + "name": "bind", + "description": "Bind a provider resource or path glob to a relay channel", + "args": [ + { + "name": "provider", + "description": "provider id", + "required": false + }, + { + "name": "resource", + "description": "provider resource or path glob", + "required": false + } + ], + "options": [ + { + "flags": "--list", + "description": "list active relay bindings as JSON", + "defaultValue": false + }, + { + "flags": "--json", + "description": "accepted for consistency with other JSON-emitting integration commands", + "defaultValue": false + }, + { + "flags": "--channel ", + "description": "relay channel to receive provider records" + }, + { + "flags": "--webhook ", + "description": "RelayCast inbound webhook id" + }, + { + "flags": "--webhook-token ", + "description": "RelayCast inbound webhook token" + }, + { + "flags": "--subscription ", + "description": "relay integration subscription id" + }, + { + "flags": "--webhook-subscription ", + "description": "relayfile-cloud inbound webhook subscription id" + }, + { + "flags": "--webhook-subscription-workspace ", + "description": "workspace the webhook subscription was created in (pairs with --webhook-subscription)" + } + ] + }, + { + "name": "resolve-path", + "description": "Resolve a provider resource to its relayfile path", + "args": [ + { + "name": "provider", + "description": "provider id", + "required": true + }, + { + "name": "resource", + "description": "provider resource identifier", + "required": true + } + ], + "options": [ + { + "flags": "--json", + "description": "emit JSON", + "defaultValue": false + } + ] + }, + { + "name": "unbind", + "description": "Remove a relay binding for a provider", + "args": [ + { + "name": "provider", + "description": "provider id", + "required": true + }, + { + "name": "resource", + "description": "path glob or resource to unbind; may be passed as --resource instead", + "required": false + } + ], + "options": [ + { + "flags": "--resource ", + "description": "path glob/resource to unbind" + } + ] + }, + { + "name": "writeback-secret", + "description": "Print the writeback secret for a bound relay channel", + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--channel ", + "description": "relay channel the binding delivers to" + }, + { + "flags": "--json", + "description": "emit JSON", + "defaultValue": false + } + ] + } + ] + }, + { + "name": "ops", + "description": "List or replay dead-lettered writeback ops", + "subcommands": [ + { + "name": "list", + "description": "List dead-lettered writeback ops", + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--json", + "description": "emit JSON", + "defaultValue": false + }, + { + "flags": "--no-refresh", + "description": "skip refreshing the local mirror from the server", + "defaultValue": false + }, + { + "flags": "--server ", + "description": "relayfile server URL override" + }, + { + "flags": "--token ", + "description": "relayfile token override" + } + ] + }, + { + "name": "replay", + "description": "Replay one dead-lettered writeback op", + "args": [ + { + "name": "op-id", + "description": "dead-lettered operation id", + "required": true + } + ], + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--cloud-api-url ", + "description": "Relayfile Cloud API URL (default: $RELAYFILE_CLOUD_API_URL or https://agentrelay.com/cloud)" + } + ] + } + ] + }, + { + "name": "writeback", + "description": "Inspect or retry local writeback failures", + "subcommands": [ + { + "name": "list", + "description": "List local writeback items by state", + "options": [ + { + "flags": "--state ", + "description": "writeback state: pending or dead" + }, + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--json", + "description": "emit JSON", + "defaultValue": false + } + ] + }, + { + "name": "push", + "description": "Push a local file to the workspace and wait for its receipt", + "args": [ + { + "name": "local-path", + "description": "local mirror path to push", + "required": true + } + ], + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--server ", + "description": "relayfile server URL override" + }, + { + "flags": "--token ", + "description": "relayfile token override" + }, + { + "flags": "--json", + "description": "emit JSON", + "defaultValue": false + }, + { + "flags": "--timeout ", + "description": "operation receipt wait timeout", + "defaultValue": "1m30s" + } + ] + }, + { + "name": "update", + "description": "Update a workspace file from its local mirror copy", + "args": [ + { + "name": "local-path", + "description": "local mirror path to update", + "required": true + } + ], + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--server ", + "description": "relayfile server URL override" + }, + { + "flags": "--token ", + "description": "relayfile token override" + }, + { + "flags": "--json", + "description": "emit JSON", + "defaultValue": false + }, + { + "flags": "--timeout ", + "description": "operation receipt wait timeout", + "defaultValue": "1m30s" + } + ] + }, + { + "name": "delete", + "description": "Delete a workspace file via its local mirror path", + "args": [ + { + "name": "local-path", + "description": "local mirror path to delete", + "required": true + } + ], + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--server ", + "description": "relayfile server URL override" + }, + { + "flags": "--token ", + "description": "relayfile token override" + }, + { + "flags": "--json", + "description": "emit JSON", + "defaultValue": false + }, + { + "flags": "--timeout ", + "description": "operation receipt wait timeout", + "defaultValue": "1m30s" + } + ] + }, + { + "name": "status", + "description": "Show local pending, failed, and dead-lettered writebacks", + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + } + ], + "options": [ + { + "flags": "--json", + "description": "emit JSON", + "defaultValue": false + } + ] + }, + { + "name": "retry", + "description": "Re-enqueue a local dead-lettered writeback op", + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + } + ], + "options": [ + { + "flags": "--op-id ", + "description": "dead-lettered operation id (also accepted as --opId)" + } + ] + }, + { + "name": "skip-stuck", + "description": "Walk the events cursor past stuck (404) events without waiting the treat-as-deleted timer", + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + } + ], + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--max ", + "description": "maximum number of stuck events to skip (0 = unbounded)", + "defaultValue": 0 + }, + { + "flags": "--json", + "description": "emit JSON", + "defaultValue": false + } + ] + }, + { + "name": "sweep-drafts", + "description": "Remove hand-named draft files left behind under a workspace subtree", + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + } + ], + "options": [ + { + "flags": "--path-prefix ", + "description": "restrict the sweep to a subtree" + }, + { + "flags": "--pattern ", + "description": "basename glob for hand-named drafts (repeatable), e.g. wb-*.json" + }, + { + "flags": "--apply", + "description": "execute removals (default is a dry run)", + "defaultValue": false + }, + { + "flags": "--json", + "description": "emit JSON", + "defaultValue": false + }, + { + "flags": "--server ", + "description": "relayfile server URL override" + }, + { + "flags": "--token ", + "description": "relayfile token override" + } + ] + } + ] + }, + { + "name": "digest", + "description": "Regenerate workspace digests", + "subcommands": [ + { + "name": "rebuild", + "description": "Regenerate daily, weekly, or date-stamped digest artifacts", + "options": [ + { + "flags": "--window ", + "description": "digest window: today, yesterday, this-week, last-week, or YYYY-MM-DD" + }, + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--json", + "description": "print machine-readable JSON", + "defaultValue": false + } + ] + } + ] + }, + { + "name": "pull", + "description": "Trigger an immediate sync refresh for one or all providers", + "options": [ + { + "flags": "--workspace ", + "description": "workspace name or id" + }, + { + "flags": "--provider ", + "description": "provider id (default: refresh all connected providers)" + }, + { + "flags": "--reason ", + "description": "free-form reason recorded server-side", + "defaultValue": "manual" + }, + { + "flags": "--server ", + "description": "relayfile server URL override" + }, + { + "flags": "--token ", + "description": "relayfile token override" + } + ] + }, + { + "name": "mount", + "description": "Mirror a remote workspace to a local directory; add --background to detach", + "aliases": [ + "start", + "on" + ], + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + }, + { + "name": "local-dir", + "description": "local mirror directory", + "required": false + } + ], + "options": [ + { + "flags": "--server ", + "description": "relayfile server URL" + }, + { + "flags": "--token ", + "description": "bearer token" + }, + { + "flags": "--creds-file ", + "description": "delegated relayfile credentials file" + }, + { + "flags": "--remote-path ", + "description": "remote root path (may be repeated)" + }, + { + "flags": "--paths-file ", + "description": "file containing remote root paths, as a JSON array or newline-separated list" + }, + { + "flags": "--local-layout ", + "description": "local directory layout: exact or scoped" + }, + { + "flags": "--provider ", + "description": "event provider filter" + }, + { + "flags": "--state-file ", + "description": "state file path" + }, + { + "flags": "--state-dir ", + "description": "directory for private mount state" + }, + { + "flags": "--mount-kind ", + "description": "private state identity kind: daemon, flush, or initial-sync" + }, + { + "flags": "--local-dir ", + "description": "local mirror directory" + }, + { + "flags": "--mode ", + "description": "mount mode: poll (recommended) or fuse" + }, + { + "flags": "--interval ", + "description": "sync interval" + }, + { + "flags": "--interval-jitter ", + "description": "sync interval jitter ratio (0.0-1.0)" + }, + { + "flags": "--timeout ", + "description": "per-sync timeout" + }, + { + "flags": "--bootstrap-timeout ", + "description": "hard cap for the one-time/full-tree bootstrap pull (0 = unbounded while making progress)" + }, + { + "flags": "--bootstrap-max-files-per-cycle ", + "description": "maximum files materialized per resumable tree-bootstrap cycle (-1 = legacy unbounded tree behavior)" + }, + { + "flags": "--full-pull-min-interval ", + "description": "minimum wall-clock interval between completed periodic full-tree audits (-1 disables the time guard)" + }, + { + "flags": "--cursor-timeout ", + "description": "independent timeout for events-cursor resolution" + }, + { + "flags": "--full-reconcile", + "description": "force one full reconcile regardless of bootstrap-complete state (escape hatch)" + }, + { + "flags": "--websocket", + "description": "enable websocket event streaming when available" + }, + { + "flags": "--low-memory", + "description": "reduce mount memory use by omitting per-file public state and deferring content reads" + }, + { + "flags": "--pprof-addr ", + "description": "optional pprof listen address, e.g. 127.0.0.1:6060" + }, + { + "flags": "--memlog-interval ", + "description": "optional interval for logging runtime memory stats" + }, + { + "flags": "--background", + "description": "detach and keep syncing in the background", + "defaultValue": false + }, + { + "flags": "--pid-file ", + "description": "pid file path for background mode" + }, + { + "flags": "--log-file ", + "description": "log file path for background mode" + }, + { + "flags": "--daemonized", + "description": "internal flag used by relayfile mount --background", + "defaultValue": false + }, + { + "flags": "--once", + "description": "run one sync cycle and exit", + "defaultValue": false + }, + { + "flags": "--reset-after-clobber", + "description": "acknowledge a mount-root clobber and authorize daemon to recreate the directory" + }, + { + "flags": "--rehome", + "description": "allow re-homing an already-registered workspace mirror to a different LOCAL_DIR", + "defaultValue": false + } + ], + "subcommands": [ + { + "name": "checkpoint-seal", + "description": "Seal a mount checkpoint for controller-driven cutover", + "options": [ + { + "flags": "--root ", + "description": "absolute local mount root" + }, + { + "flags": "--lifecycle-id ", + "description": "stable controller-persisted cutover lifecycle id" + }, + { + "flags": "--session ", + "description": "live session identifier" + }, + { + "flags": "--generation ", + "description": "strictly increasing migration generation", + "defaultValue": 0 + }, + { + "flags": "--timeout ", + "description": "checkpoint deadline", + "defaultValue": "30s" + }, + { + "flags": "--ttl ", + "description": "server receipt TTL" + }, + { + "flags": "--json", + "description": "emit the machine contract", + "defaultValue": false + } + ], + "hidden": true + }, + { + "name": "resume-seal", + "description": "Resume a sealed mount checkpoint on the destination host", + "options": [ + { + "flags": "--root ", + "description": "absolute local mount root" + }, + { + "flags": "--timeout ", + "description": "resume readiness deadline" + }, + { + "flags": "--json", + "description": "emit the machine contract", + "defaultValue": false + } + ], + "hidden": true + }, + { + "name": "verify-seal", + "description": "Verify a resumed mount checkpoint and recover if needed", + "options": [ + { + "flags": "--root ", + "description": "absolute local mount root" + }, + { + "flags": "--timeout ", + "description": "verification and recovery deadline" + }, + { + "flags": "--json", + "description": "emit the machine contract", + "defaultValue": false + } + ], + "hidden": true + }, + { + "name": "handback-seal", + "description": "Drain and hand a verified mount back to its original host", + "options": [ + { + "flags": "--root ", + "description": "absolute local mount root" + }, + { + "flags": "--timeout ", + "description": "final drain and handback deadline" + }, + { + "flags": "--json", + "description": "emit the machine contract", + "defaultValue": false + } + ], + "hidden": true + } + ] + }, + { + "name": "restart", + "description": "Stop and start a workspace's mount in one step (--foreground to attach)", + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + } + ], + "options": [ + { + "flags": "--foreground", + "description": "run the restarted mount in the foreground instead of detaching", + "defaultValue": false + } + ] + }, + { + "name": "supervisor", + "description": "Install/uninstall/status launchd (macOS) or systemd (Linux) service for auto-restart", + "subcommands": [ + { + "name": "install", + "description": "Install the auto-restart service for a workspace mount", + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + } + ], + "options": [ + { + "flags": "--interval ", + "description": "sync interval passed through to the supervised listen process" + } + ] + }, + { + "name": "uninstall", + "description": "Remove the auto-restart service", + "aliases": [ + "remove" + ] + }, + { + "name": "status", + "description": "Show the auto-restart service state" + } + ] + }, + { + "name": "tree", + "description": "List a remote workspace path", + "aliases": [ + "ls" + ], + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + }, + { + "name": "path", + "description": "remote path to list; defaults to /", + "required": false + } + ], + "options": [ + { + "flags": "--server ", + "description": "relayfile server URL override" + }, + { + "flags": "--token ", + "description": "relayfile token override" + }, + { + "flags": "--path ", + "description": "remote path to list", + "defaultValue": "/" + }, + { + "flags": "--depth ", + "description": "tree depth", + "defaultValue": 1 + }, + { + "flags": "--json", + "description": "print the raw JSON response", + "defaultValue": false + } + ] + }, + { + "name": "read", + "description": "Print a remote file's content", + "aliases": [ + "cat" + ], + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + }, + { + "name": "path", + "description": "remote file path (required; the sole positional when no workspace is given)", + "required": false + } + ], + "options": [ + { + "flags": "--server ", + "description": "relayfile server URL override" + }, + { + "flags": "--token ", + "description": "relayfile token override" + }, + { + "flags": "--output ", + "description": "output file path or - for stdout", + "defaultValue": "-" + }, + { + "flags": "--json", + "description": "print the raw JSON response", + "defaultValue": false + } + ] + }, + { + "name": "seed", + "description": "Upload a directory tree with bulk writes", + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + }, + { + "name": "dir", + "description": "local directory to upload; defaults to the current directory", + "required": false + } + ], + "options": [ + { + "flags": "--server ", + "description": "relayfile server URL override" + }, + { + "flags": "--token ", + "description": "relayfile token override" + } + ] + }, + { + "name": "export", + "description": "Export a workspace as json, tar, or patch", + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + } + ], + "options": [ + { + "flags": "--server ", + "description": "relayfile server URL override" + }, + { + "flags": "--token ", + "description": "relayfile token override" + }, + { + "flags": "--format ", + "description": "export format: tar, json, or patch", + "defaultValue": "json" + }, + { + "flags": "--output ", + "description": "output file path or - for stdout", + "defaultValue": "-" + } + ] + }, + { + "name": "status", + "description": "Show sync status and local mirror state for a workspace", + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + } + ], + "options": [ + { + "flags": "--server ", + "description": "relayfile server URL override" + }, + { + "flags": "--token ", + "description": "relayfile token override" + }, + { + "flags": "--json", + "description": "emit JSON", + "defaultValue": false + } + ] + }, + { + "name": "stop", + "description": "Stop a background mount", + "aliases": [ + "off" + ], + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + } + ] + }, + { + "name": "logs", + "description": "Print the background mount log", + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + } + ], + "options": [ + { + "flags": "--lines ", + "description": "number of lines to print", + "defaultValue": 40 + } + ] + }, + { + "name": "observer", + "description": "Open the hosted file observer for a workspace", + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + } + ], + "options": [ + { + "flags": "--server ", + "description": "relayfile server URL override" + }, + { + "flags": "--token ", + "description": "relayfile token override" + }, + { + "flags": "--url ", + "description": "observer URL (default: $RELAYFILE_OBSERVER_URL or the hosted observer)" + }, + { + "flags": "--no-open", + "description": "print the observer URL without opening a browser", + "defaultValue": false + } + ] + }, + { + "name": "listen", + "description": "Stream workspace file events, optionally running a command per event", + "aliases": [ + "watch" + ], + "options": [ + { + "flags": "--server ", + "description": "relayfile server URL override" + }, + { + "flags": "--token ", + "description": "relayfile token override" + }, + { + "flags": "--provider ", + "description": "filter to a specific provider (e.g. linear, notion)" + }, + { + "flags": "--path ", + "description": "glob path filter (e.g. /linear/issues/**)" + }, + { + "flags": "--event ", + "description": "event type filter: file.created, file.updated, file.deleted" + }, + { + "flags": "--run ", + "description": "shell command per event; supports {{path}}, {{type}}, {{provider}}, {{revision}}, {{event}}" + }, + { + "flags": "--format ", + "description": "output format when --run is not set: text or json", + "defaultValue": "text" + }, + { + "flags": "--background", + "description": "run in background; logs to ~/.relayfile/listen.log", + "defaultValue": false + }, + { + "flags": "--daemonized", + "description": "internal flag used by relayfile listen --background", + "defaultValue": false + } + ] + }, + { + "name": "control-plane", + "description": "Serve the local relayfile control-plane socket", + "subcommands": [ + { + "name": "serve", + "description": "Serve the control-plane unix socket", + "options": [ + { + "flags": "--sock ", + "description": "unix socket path (default: the per-user relayfile socket)" + } + ] + } + ] + }, + { + "name": "dev", + "description": "Print workspace context, then stream file events like `listen`", + "options": [ + { + "flags": "--server ", + "description": "relayfile server URL override" + }, + { + "flags": "--token ", + "description": "relayfile token override" + }, + { + "flags": "--provider ", + "description": "filter to a specific provider (e.g. linear, notion)" + }, + { + "flags": "--path ", + "description": "glob path filter (e.g. /linear/issues/**)" + }, + { + "flags": "--event ", + "description": "event type filter: file.created, file.updated, file.deleted" + }, + { + "flags": "--run ", + "description": "shell command per event; supports {{path}}, {{type}}, {{provider}}, {{revision}}, {{event}}" + }, + { + "flags": "--format ", + "description": "output format when --run is not set: text or json", + "defaultValue": "text" + }, + { + "flags": "--background", + "description": "run in background; logs to ~/.relayfile/listen.log", + "defaultValue": false + }, + { + "flags": "--daemonized", + "description": "internal flag used by relayfile listen --background", + "defaultValue": false + } + ], + "hidden": true + } +] diff --git a/packages/sdk/typescript/src/relay-cli/command-spec.test.ts b/packages/sdk/typescript/src/relay-cli/command-spec.test.ts new file mode 100644 index 00000000..f7e58f57 --- /dev/null +++ b/packages/sdk/typescript/src/relay-cli/command-spec.test.ts @@ -0,0 +1,80 @@ +import { readFileSync } from "node:fs" +import path from "node:path" + +import { describe, expect, it } from "vitest" +import { walkCommands } from "@agent-relay/cli-surface" + +import { COMMAND_SPEC_PATH, relayfileCommands } from "./index.js" +import { emitCommandSpec } from "../../scripts/gen-command-spec.mjs" + +/** + * The snapshot in command-spec.json is generated from the Go CLI's own command + * table (`relayfile __command-spec --json`). It exists so `commands` is + * available without the binary at import time — which means it can rot. This + * test regenerates it from the real binary and diffs, so it cannot. + */ + +describe("command-spec.json", () => { + it("matches what the Go command tree emits", () => { + const emitted = emitCommandSpec() + const snapshot = readFileSync(COMMAND_SPEC_PATH, "utf8") + if (emitted !== snapshot) { + // Point at the fix rather than dumping 1.6k lines of JSON diff. + const emittedTree = JSON.parse(emitted) + const snapshotTree = JSON.parse(snapshot) + expect(snapshotTree, "run: npm run gen:command-spec").toEqual(emittedTree) + // Identical trees but different bytes: formatting drift. + expect(snapshot, "run: npm run gen:command-spec").toBe(emitted) + } + }, 180_000) + + it("is loaded from the path the build copies into dist", () => { + expect(path.basename(COMMAND_SPEC_PATH)).toBe("command-spec.json") + expect(() => readFileSync(COMMAND_SPEC_PATH, "utf8")).not.toThrow() + }) + + it("describes a non-trivial tree with nested groups", () => { + const commands = relayfileCommands() + expect(commands.length).toBeGreaterThan(10) + + const names = commands.map((command) => command.name) + for (const expected of ["setup", "mount", "status", "workspace", "integration"]) { + expect(names).toContain(expected) + } + + const workspace = commands.find((command) => command.name === "workspace") + expect(workspace?.subcommands?.map((sub) => sub.name)).toEqual( + expect.arrayContaining(["create", "join", "use", "list", "current", "view", "status", "delete"]) + ) + + // `workspace view` proves three-level nesting survives the round trip. + const view = workspace?.subcommands?.find((sub) => sub.name === "view") + expect(view?.subcommands?.map((sub) => sub.name)).toEqual(["add", "list", "remove"]) + }) + + it("carries no machine-specific default values", () => { + // The snapshot is checked in, so a default derived from the generating + // machine's environment (home directory, socket path, server override) + // would both leak a local path and make the snapshot unreproducible. + const offenders: string[] = [] + for (const { path: commandPath, command } of walkCommands(relayfileCommands())) { + for (const option of command.options ?? []) { + if (option.defaultValue === undefined) continue + const value = String(option.defaultValue) + if (value.includes("/home/") || value.includes("/Users/") || value.includes(path.sep + "tmp")) { + offenders.push(`${commandPath.join(" ")} ${option.flags} = ${value}`) + } + } + } + expect(offenders).toEqual([]) + }) + + it("keeps the introspection hook out of the published tree", () => { + const names = new Set() + for (const { command } of walkCommands(relayfileCommands())) { + names.add(command.name) + } + expect(names.has("__command-spec")).toBe(false) + expect(names.has("help")).toBe(false) + }) +}) diff --git a/packages/sdk/typescript/src/relay-cli/index.ts b/packages/sdk/typescript/src/relay-cli/index.ts new file mode 100644 index 00000000..c594d763 --- /dev/null +++ b/packages/sdk/typescript/src/relay-cli/index.ts @@ -0,0 +1,269 @@ +/** + * relayfile's CLI surface, mounted by the `agent-relay` CLI as + * `agent-relay file`. + * + * The surface does not reimplement any relayfile command. `commands` is a + * checked-in snapshot of the Go binary's own command table (emitted by + * `relayfile __command-spec --json`, regenerated by `npm run gen:command-spec`) + * and `run` spawns that same binary, so `agent-relay file ` and + * `relayfile ` are the same code path. + * + * Structurally typed against `@agent-relay/cli-surface` (a devDependency): + * this package gains no runtime dependency on relay. + */ + +import { spawn } from "node:child_process" +import { readFileSync } from "node:fs" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" + +import { RELAYFILE_VERSION } from "../package-version.js" +import { + announceSetupIntent, + prepareCloudSession, + type EnsureCloudSession +} from "./cloud-preflight.js" +import { + GO_TOOLCHAIN_MISSING_MESSAGE, + resolveRelayfileBinary, + type ResolveRelayfileBinaryOptions +} from "./resolve-binary.js" + +/** Contract revision implemented here; mirrors `@agent-relay/cli-surface`. */ +export const RELAY_CLI_CONTRACT_VERSION = 1 + +/** Exit code for an argv the surface cannot route. */ +export const RELAY_CLI_EXIT_UNKNOWN_COMMAND = 2 + +export interface RelayCliIo { + stdout(chunk: string): void + stderr(chunk: string): void +} + +export interface RelayCliArgSpec { + name: string + description: string + required: boolean + variadic?: boolean +} + +export interface RelayCliOptionSpec { + flags: string + description: string + defaultValue?: string | boolean | number +} + +export interface RelayCliCommandSpec { + name: string + description: string + aliases?: readonly string[] + args?: readonly RelayCliArgSpec[] + options?: readonly RelayCliOptionSpec[] + subcommands?: readonly RelayCliCommandSpec[] + deprecated?: { replacement: string; since?: string } + hidden?: boolean +} + +export interface RelayCliSurface { + id: string + version: string + contract: 1 + commands: readonly RelayCliCommandSpec[] + run(argv: readonly string[], io: RelayCliIo): Promise +} + +export interface CreateRelayCliSurfaceOptions { + /** Binary lookup overrides; forwarded to `resolveRelayfileBinary`. */ + resolve?: ResolveRelayfileBinaryOptions + /** Environment for the child process and the Cloud preflight. */ + env?: NodeJS.ProcessEnv + /** Working directory for the child process. */ + cwd?: string + /** + * Skip the Cloud sign-in preflight. Only for tests: skipping it makes + * `agent-relay file setup` behave differently from `relayfile setup`. + */ + skipCloudPreflight?: boolean + /** Injected Cloud SDK entry point, for tests. */ + ensureCloudSession?: EnsureCloudSession +} + +/** + * Path of the checked-in command-tree snapshot. + * + * Lives next to the compiled module so the published package carries it. + */ +export const COMMAND_SPEC_PATH = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "command-spec.json" +) + +let cachedCommands: readonly RelayCliCommandSpec[] | undefined + +/** + * Read the snapshot of the Go binary's command tree. + * + * Read from disk rather than imported so the module needs no JSON import + * attributes and stays loadable from both ESM and a CJS `import()`. + * + * @returns The command tree the relayfile binary dispatches. + */ +export function relayfileCommands(): readonly RelayCliCommandSpec[] { + if (!cachedCommands) { + cachedCommands = JSON.parse( + readFileSync(COMMAND_SPEC_PATH, "utf8") + ) as readonly RelayCliCommandSpec[] + } + return cachedCommands +} + +/** + * Every name and alias the relayfile binary routes at the top level. + * + * Includes `help` and `__command-spec`, which the binary routes but keeps out + * of its published surface (the host renders help itself, and + * `__command-spec` is the introspection hook that produces the snapshot). + * + * @returns The routable top-level tokens. + */ +export function routableTopLevelNames(): readonly string[] { + const names = new Set(["help", "__command-spec"]) + for (const command of relayfileCommands()) { + names.add(command.name) + for (const alias of command.aliases ?? []) { + names.add(alias) + } + } + return [...names] +} + +function isFlag(token: string): boolean { + return token.startsWith("-") +} + +/** + * Exit code convention for a child killed by a signal: 128 + signal number, so + * callers can tell user cancellation (130 for SIGINT) from a generic failure. + * + * @returns The exit code to report for `signal`. + */ +function exitCodeForSignal(signal: NodeJS.Signals): number { + const signum = (os.constants.signals as Record)[signal] + return typeof signum === "number" ? 128 + signum : 1 +} + +/** + * Create relayfile's mountable CLI surface. + * + * @param options - Optional overrides for binary lookup, env, and cwd. + * @returns A surface satisfying `@agent-relay/cli-surface`'s `RelayCliSurface`. + */ +export function createRelayCliSurface( + options: CreateRelayCliSurfaceOptions = {} +): RelayCliSurface { + const commands = relayfileCommands() + + return { + id: "relayfile", + version: RELAYFILE_VERSION, + contract: RELAY_CLI_CONTRACT_VERSION, + commands, + + async run(argv: readonly string[], io: RelayCliIo): Promise { + const args = [...argv] + const env = options.env ?? process.env + + const first = args[0] + if (first !== undefined && !isFlag(first) && !routableTopLevelNames().includes(first)) { + io.stderr(`unknown command "${first}"\n`) + io.stderr( + `run \`agent-relay file --help\` for the relayfile command tree\n` + ) + return RELAY_CLI_EXIT_UNKNOWN_COMMAND + } + + if (!options.skipCloudPreflight) { + // Agent Relay's Cloud SDK owns interactive login, token refresh, + // locking, and the canonical session store; the native runtime reads + // that same store directly. Running this here keeps + // `agent-relay file setup` identical to `relayfile setup`. + announceSetupIntent(args, env, (line) => io.stdout(`${line}\n`)) + await prepareCloudSession(args, { + env, + writeLine: (line) => io.stdout(`${line}\n`), + ensureCloudSession: options.ensureCloudSession + }) + } + + const resolution = resolveRelayfileBinary(options.resolve) + const command = resolution.command + const childArgs = [...resolution.args, ...args] + const cwd = resolution.kind === "go-run" ? resolution.cwd : options.cwd + + return await new Promise((resolve, reject) => { + // stdin is inherited so interactive prompts (setup, login, delete + // confirmations) still work; stdout/stderr are piped into `io` because + // the contract forbids writing to the host's streams directly. No + // signal handlers are installed: the host owns them. + const child = spawn(command, childArgs, { + cwd, + env, + stdio: ["inherit", "pipe", "pipe"] + }) + + child.stdout?.setEncoding("utf8") + child.stderr?.setEncoding("utf8") + child.stdout?.on("data", (chunk: string) => io.stdout(chunk)) + child.stderr?.on("data", (chunk: string) => io.stderr(chunk)) + + child.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" && resolution.kind === "go-run") { + io.stderr(`${GO_TOOLCHAIN_MISSING_MESSAGE}\n`) + resolve(1) + return + } + reject(error) + }) + + child.on("close", (code, signal) => { + if (typeof code === "number") { + resolve(code) + return + } + if (signal) { + resolve(exitCodeForSignal(signal)) + return + } + resolve(1) + }) + }) + } + } +} + +export { + RelayfileBinaryNotFoundError, + resolveRelayfileBinary, + findSourceCheckoutRoot, + genericBinaryName, + platformBinaryName, + GO_TOOLCHAIN_MISSING_MESSAGE, + type RelayfileBinaryResolution, + type ResolveRelayfileBinaryOptions +} from "./resolve-binary.js" + +export { + announceSetupIntent, + hasValidSetupArguments, + parseGoDurationMilliseconds, + parseSetupArguments, + prepareCloudSession, + shouldPrepareCloudSession, + loadCloudSessionSDK, + DEFAULT_CLOUD_API_URL, + SETUP_INTENT, + SETUP_INTENT_PRINTED_ENV, + type CloudPreflightOptions, + type EnsureCloudSession +} from "./cloud-preflight.js" diff --git a/packages/sdk/typescript/src/relay-cli/resolve-binary.test.ts b/packages/sdk/typescript/src/relay-cli/resolve-binary.test.ts new file mode 100644 index 00000000..39d21b15 --- /dev/null +++ b/packages/sdk/typescript/src/relay-cli/resolve-binary.test.ts @@ -0,0 +1,169 @@ +import { existsSync, readFileSync } from "node:fs" +import path from "node:path" + +import { describe, expect, it } from "vitest" + +import { + RelayfileBinaryNotFoundError, + findSourceCheckoutRoot, + platformBinaryName, + resolveRelayfileBinary +} from "./resolve-binary.js" +import { checkoutRoot } from "./testing/build-binary.js" + +/** + * Binary resolution used to live in packages/cli/scripts/run.js. These tests + * pin the behavior that moved here, including the fallbacks the bin shim + * depends on. + */ + +function fakeFs(present: readonly string[]): (candidate: string) => boolean { + const set = new Set(present) + return (candidate) => set.has(candidate) +} + +describe("platformBinaryName", () => { + it("maps node platform/arch onto the packaged Go binary names", () => { + expect(platformBinaryName("linux", "x64")).toBe("relayfile-cli-linux-amd64") + expect(platformBinaryName("linux", "arm64")).toBe("relayfile-cli-linux-arm64") + expect(platformBinaryName("darwin", "arm64")).toBe("relayfile-cli-darwin-arm64") + expect(platformBinaryName("win32", "x64")).toBe("relayfile-cli-windows-amd64.exe") + }) + + it("returns null for an unsupported target", () => { + expect(platformBinaryName("aix", "x64")).toBeNull() + expect(platformBinaryName("linux", "ppc64")).toBeNull() + }) +}) + +describe("resolveRelayfileBinary", () => { + it("prefers a locally built generic binary over the packaged one", () => { + const binDir = path.join("/pkg", "bin") + const generic = path.join(binDir, "relayfile") + const packaged = path.join(binDir, "relayfile-cli-linux-amd64") + const resolution = resolveRelayfileBinary({ + binDirs: [binDir], + platform: "linux", + arch: "x64", + fileExists: fakeFs([generic, packaged]) + }) + expect(resolution).toEqual({ + kind: "binary", + command: generic, + args: [], + binaryPath: generic + }) + }) + + it("falls back to the packaged per-platform binary", () => { + const binDir = path.join("/pkg", "bin") + const packaged = path.join(binDir, "relayfile-cli-darwin-arm64") + const resolution = resolveRelayfileBinary({ + binDirs: [binDir], + platform: "darwin", + arch: "arm64", + fileExists: fakeFs([packaged]) + }) + expect(resolution).toMatchObject({ kind: "binary", command: packaged }) + }) + + it("uses the .exe name on Windows", () => { + const binDir = path.join("/pkg", "bin") + const generic = path.join(binDir, "relayfile.exe") + const resolution = resolveRelayfileBinary({ + binDirs: [binDir], + platform: "win32", + arch: "x64", + fileExists: fakeFs([generic]) + }) + expect(resolution).toMatchObject({ kind: "binary", command: generic }) + }) + + it("searches bin directories in the order given", () => { + const first = path.join("/first", "bin") + const second = path.join("/second", "bin") + const secondBinary = path.join(second, "relayfile") + const resolution = resolveRelayfileBinary({ + binDirs: [first, second], + platform: "linux", + arch: "x64", + fileExists: fakeFs([secondBinary]) + }) + expect(resolution).toMatchObject({ command: secondBinary }) + }) + + it("runs from Go source in a checkout when no binary is built", () => { + // postinstall intentionally skips building the binary in a source + // checkout; without this fallback the installed command is unusable there. + const repoRoot = path.join("/work", "relayfile") + const resolution = resolveRelayfileBinary({ + binDirs: [], + searchFrom: [path.join(repoRoot, "packages", "cli", "scripts")], + platform: "linux", + arch: "x64", + fileExists: fakeFs([ + path.join(repoRoot, "go.mod"), + path.join(repoRoot, "cmd", "relayfile-cli") + ]) + }) + expect(resolution).toEqual({ + kind: "go-run", + command: "go", + args: ["run", "./cmd/relayfile-cli"], + cwd: repoRoot + }) + }) + + it("throws a reinstall hint when nothing is usable", () => { + expect(() => + resolveRelayfileBinary({ + binDirs: [], + searchFrom: [path.join("/nowhere", "deep")], + platform: "linux", + arch: "x64", + fileExists: fakeFs([]) + }) + ).toThrowError(RelayfileBinaryNotFoundError) + + try { + resolveRelayfileBinary({ + binDirs: [], + searchFrom: [path.join("/nowhere", "deep")], + platform: "aix", + arch: "ppc64", + fileExists: fakeFs([]) + }) + } catch (error) { + expect((error as Error).message).toBe( + "relayfile binary not found for aix ppc64. Reinstall the package or run postinstall again." + ) + } + }) +}) + +describe("findSourceCheckoutRoot", () => { + it("finds this repo from inside the SDK", () => { + const root = checkoutRoot() + expect(existsSync(path.join(root, "go.mod"))).toBe(true) + expect(existsSync(path.join(root, "cmd", "relayfile-cli"))).toBe(true) + }) + + it("requires both go.mod and cmd/relayfile-cli", () => { + const partial = path.join("/work", "other-go-project") + expect( + findSourceCheckoutRoot(partial, fakeFs([path.join(partial, "go.mod")])) + ).toBeNull() + }) +}) + +describe("single implementation", () => { + it("is the only place the repo looks for the relayfile binary", () => { + // The bin shim must delegate here rather than keep its own copy of the + // platform mapping. + const shim = path.join(checkoutRoot(), "packages", "cli", "scripts", "run.js") + const source = readFileSync(shim, "utf8") + expect(source).toContain("resolveRelayfileBinary") + expect(source).not.toContain("relayfile-cli-") + expect(source).not.toMatch(/PLATFORM_MAP|ARCH_MAP/) + }) +}) diff --git a/packages/sdk/typescript/src/relay-cli/resolve-binary.ts b/packages/sdk/typescript/src/relay-cli/resolve-binary.ts new file mode 100644 index 00000000..ae9baa6b --- /dev/null +++ b/packages/sdk/typescript/src/relay-cli/resolve-binary.ts @@ -0,0 +1,239 @@ +/** + * The one implementation of "find the relayfile binary". + * + * The real relayfile CLI is the Go binary (`cmd/relayfile-cli`). Both entry + * points into it — the `relayfile` npm package's bin shim + * (`packages/cli/scripts/run.js`) and the `agent-relay file` CLI surface in + * this directory — resolve it through this module. Nothing else in the repo + * may reimplement the lookup. + */ + +import { createRequire } from "node:module" +import { existsSync } from "node:fs" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const PLATFORM_MAP: Record = { + darwin: "darwin", + linux: "linux", + win32: "windows" +} + +const ARCH_MAP: Record = { + x64: "amd64", + arm64: "arm64" +} + +/** How the resolver decided to launch relayfile. */ +export type RelayfileBinaryResolution = + | { + /** A packaged or locally built binary was found. */ + kind: "binary" + command: string + args: readonly string[] + /** Absolute path of the binary that matched. */ + binaryPath: string + } + | { + /** + * No binary was found, but we are inside a source checkout, so relayfile + * runs straight from Go source. `postinstall` intentionally skips + * building the binary in a checkout; without this fallback the installed + * `relayfile` command would be unusable there. + */ + kind: "go-run" + command: "go" + args: readonly string[] + cwd: string + } + +export interface ResolveRelayfileBinaryOptions { + /** + * Directories to search for a packaged binary, highest priority first. + * Defaults to the `bin` directory of the installed `relayfile` package plus + * the workspace copy when running inside this repo. + */ + binDirs?: readonly string[] + /** Extra directories to start the source-checkout search from. */ + searchFrom?: readonly string[] + platform?: string + arch?: string + /** Injected for tests. */ + fileExists?: (candidate: string) => boolean +} + +/** Thrown when neither a binary nor a usable source checkout was found. */ +export class RelayfileBinaryNotFoundError extends Error { + readonly platform: string + readonly arch: string + + constructor(platform: string, arch: string) { + super( + `relayfile binary not found for ${platform} ${arch}. Reinstall the package or run postinstall again.` + ) + this.name = "RelayfileBinaryNotFoundError" + this.platform = platform + this.arch = arch + } +} + +/** + * Name of the per-platform binary shipped inside the `relayfile` package. + * + * @returns The packaged binary's file name, or null on an unsupported target. + */ +export function platformBinaryName( + platform: string = os.platform(), + arch: string = os.arch() +): string | null { + const goPlatform = PLATFORM_MAP[platform] + const goArch = ARCH_MAP[arch] + if (!goPlatform || !goArch) { + return null + } + const extension = goPlatform === "windows" ? ".exe" : "" + return `relayfile-cli-${goPlatform}-${goArch}${extension}` +} + +/** + * Name of the binary the CLI package installs and runs, `bin/relayfile`. + * + * @param platform - Node platform id; defaults to this host's. + * @returns The file name, with `.exe` on Windows. + */ +export function genericBinaryName(platform: string = os.platform()): string { + return platform === "win32" ? "relayfile.exe" : "relayfile" +} + +function moduleDirectory(): string { + return path.dirname(fileURLToPath(import.meta.url)) +} + +/** + * Walk up from `start` looking for a directory that satisfies `matches`. + * + * @returns The matching directory, or null when the filesystem root is reached. + */ +function findUpward( + start: string, + matches: (directory: string) => boolean +): string | null { + let current = path.resolve(start) + for (;;) { + if (matches(current)) { + return current + } + const parent = path.dirname(current) + if (parent === current) { + return null + } + current = parent + } +} + +/** + * Locate the `bin` directory of the installed `relayfile` CLI package. + * + * @returns The directory, or null when the package is not installed here. + */ +function installedCliBinDir(): string | null { + try { + const require = createRequire(import.meta.url) + const manifest = require.resolve("relayfile/package.json") + return path.join(path.dirname(manifest), "bin") + } catch { + return null + } +} + +/** + * Locate `packages/cli/bin` when running from inside this repo, where the SDK + * and the CLI package are siblings rather than dependencies. + * + * @returns The directory, or null outside a checkout. + */ +function workspaceCliBinDir(exists: (candidate: string) => boolean): string | null { + const repoRoot = findUpward(moduleDirectory(), (directory) => + exists(path.join(directory, "packages", "cli", "package.json")) + ) + return repoRoot ? path.join(repoRoot, "packages", "cli", "bin") : null +} + +/** + * Locate a relayfile source checkout: a directory with both `go.mod` and + * `cmd/relayfile-cli`. + * + * @returns The checkout root, or null when there is none above `start`. + */ +export function findSourceCheckoutRoot( + start: string, + exists: (candidate: string) => boolean = existsSync +): string | null { + return findUpward( + start, + (directory) => + exists(path.join(directory, "go.mod")) && + exists(path.join(directory, "cmd", "relayfile-cli")) + ) +} + +/** + * Resolve how to launch the relayfile CLI on this machine. + * + * Search order, preserving the behavior of the `relayfile` bin shim: + * 1. a generic `bin/relayfile` (a locally built binary), then the + * per-platform `bin/relayfile-cli--` the package ships; + * 2. `go run ./cmd/relayfile-cli` from an enclosing source checkout. + * + * @param options - Search overrides; all are optional. + * @returns The command, argv prefix, and cwd to spawn. + * @throws {RelayfileBinaryNotFoundError} When nothing usable was found. + */ +export function resolveRelayfileBinary( + options: ResolveRelayfileBinaryOptions = {} +): RelayfileBinaryResolution { + const exists = options.fileExists ?? existsSync + const platform = options.platform ?? os.platform() + const arch = options.arch ?? os.arch() + + const binDirs = + options.binDirs ?? + [installedCliBinDir(), workspaceCliBinDir(exists)].filter( + (directory): directory is string => Boolean(directory) + ) + + const packagedName = platformBinaryName(platform, arch) + for (const binDir of binDirs) { + const candidates = [ + path.join(binDir, genericBinaryName(platform)), + packagedName ? path.join(binDir, packagedName) : null + ].filter((candidate): candidate is string => Boolean(candidate)) + + for (const candidate of candidates) { + if (exists(candidate)) { + return { kind: "binary", command: candidate, args: [], binaryPath: candidate } + } + } + } + + const searchRoots = [...(options.searchFrom ?? []), moduleDirectory(), process.cwd()] + for (const root of searchRoots) { + const checkout = findSourceCheckoutRoot(root, exists) + if (checkout) { + return { + kind: "go-run", + command: "go", + args: ["run", "./cmd/relayfile-cli"], + cwd: checkout + } + } + } + + throw new RelayfileBinaryNotFoundError(platform, arch) +} + +/** Message shown when a source checkout was found but Go is not installed. */ +export const GO_TOOLCHAIN_MISSING_MESSAGE = + "relayfile binary not found and Go is not installed to run from source. " + + "Install Go or run `npm run build --workspace=packages/cli`." diff --git a/packages/sdk/typescript/src/relay-cli/surface.test.ts b/packages/sdk/typescript/src/relay-cli/surface.test.ts new file mode 100644 index 00000000..ea19095b --- /dev/null +++ b/packages/sdk/typescript/src/relay-cli/surface.test.ts @@ -0,0 +1,211 @@ +import { beforeAll, describe, expect, it } from "vitest" +import { + assertSurfaceConforms, + findSurfaceViolations, + walkCommands, + RELAY_CLI_EXIT_UNKNOWN_COMMAND, + type RelayCliSurface +} from "@agent-relay/cli-surface" + +import { createRelayCliSurface, relayfileCommands } from "./index.js" +import { buildRelayfileBinary } from "./testing/build-binary.js" + +/** + * These tests run the real relayfile Go binary. Nothing here is stubbed: the + * surface resolves a binary built from `cmd/relayfile-cli` and spawns it, so a + * passing run proves `agent-relay file ` reaches the actual + * implementation and returns its actual exit code. + */ + +let binDir: string + +beforeAll(() => { + binDir = buildRelayfileBinary().binDir + // 3 minutes: a cold `go build` of cmd/relayfile-cli on a clean module cache. +}, 180_000) + +function surface(): RelayCliSurface { + return createRelayCliSurface({ + resolve: { binDirs: [binDir] }, + // Cloud sign-in is exercised in cloud-preflight.test.ts; these tests must + // never open a browser or touch the caller's Cloud session. + skipCloudPreflight: true + }) +} + +interface Capture { + stdout: string + stderr: string +} + +async function invoke(argv: readonly string[]): Promise { + const captured: Capture = { stdout: "", stderr: "" } + const code = await surface().run(argv, { + stdout: (chunk) => { + captured.stdout += chunk + }, + stderr: (chunk) => { + captured.stderr += chunk + } + }) + return { ...captured, code } +} + +describe("createRelayCliSurface", () => { + it("satisfies the RelayCliSurface contract", () => { + const created = surface() + expect(created.id).toBe("relayfile") + expect(created.contract).toBe(1) + expect(created.version).toMatch(/^\d+\.\d+\.\d+/) + expect(created.commands.length).toBeGreaterThan(0) + expect(findSurfaceViolations(created)).toEqual([]) + expect(() => assertSurfaceConforms(created)).not.toThrow() + }) + + it("is structurally assignable to the contract type", () => { + // Type-level assertion: the surface must satisfy RelayCliSurface without + // importing anything from @agent-relay/cli-surface at runtime. + const assignable: RelayCliSurface = surface() + expect(assignable.id).toBe("relayfile") + }) +}) + +describe("command tree drift", () => { + it("routes every command it declares", async () => { + // Every spec'd command must be reachable. `--help` short-circuits inside + // the binary before any network or credential work, so this sweeps the + // whole declared tree against the real dispatcher. + const failures: string[] = [] + for (const { path } of walkCommands(relayfileCommands())) { + const result = await invoke([...path, "--help"]) + if (result.code !== 0) { + failures.push( + `${path.join(" ")} --help exited ${result.code}: ${result.stderr.trim()}` + ) + continue + } + if (!result.stdout.trim()) { + failures.push(`${path.join(" ")} --help printed nothing`) + } + } + expect(failures).toEqual([]) + }, 120_000) + + it("declares every command the binary routes", async () => { + // The reverse direction. The binary rejects an unknown top-level command, + // so anything it accepts but we do not declare would be invisible to + // `agent-relay file --help`. command-spec.test.ts pins the tree itself to + // the binary's own table; this checks the routing edge. + const unknown = await invoke(["definitely-not-a-relayfile-command"]) + expect(unknown.code).toBe(RELAY_CLI_EXIT_UNKNOWN_COMMAND) + + const declared = new Set() + for (const command of relayfileCommands()) { + declared.add(command.name) + for (const alias of command.aliases ?? []) { + declared.add(alias) + } + } + // `help` and `__command-spec` are the two documented exceptions: the host + // renders help itself, and `__command-spec` is the introspection hook that + // produces the snapshot. + for (const routable of ["help", "__command-spec"]) { + expect(declared.has(routable)).toBe(false) + } + }, 60_000) + + it("declares aliases the binary actually accepts", async () => { + const aliased = relayfileCommands().filter((command) => command.aliases?.length) + expect(aliased.length).toBeGreaterThan(0) + for (const command of aliased) { + for (const alias of command.aliases ?? []) { + const result = await invoke([alias, "--help"]) + expect(result.code, `${alias} --help`).toBe(0) + } + } + }, 60_000) +}) + +describe("run", () => { + it("returns the binary's real exit code and output", async () => { + const version = await invoke(["--version"]) + expect(version.code).toBe(0) + expect(version.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/) + expect(version.stderr).toBe("") + }) + + it("returns a non-zero code from a real failure", async () => { + // `workspace use` with a workspace that cannot exist fails inside the + // binary; the surface must surface its code, not swallow it. + const result = await invoke(["workspace", "use", "surface-test-missing-workspace"]) + expect(result.code).not.toBe(0) + expect(result.stderr).not.toBe("") + }, 30_000) + + it("reports an unknown command as exit 2 without spawning", async () => { + const result = await invoke(["nope"]) + expect(result.code).toBe(RELAY_CLI_EXIT_UNKNOWN_COMMAND) + expect(result.stderr).toContain('unknown command "nope"') + expect(result.stdout).toBe("") + }) + + it("passes flag-leading argv through to the binary", async () => { + // A leading flag is not a command name, so it must reach the binary + // 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") + }) + + it("writes only through the injected io", async () => { + const stdoutWrite = process.stdout.write + const stderrWrite = process.stderr.write + const direct: string[] = [] + process.stdout.write = ((chunk: string) => { + direct.push(String(chunk)) + return true + }) as typeof process.stdout.write + process.stderr.write = ((chunk: string) => { + direct.push(String(chunk)) + return true + }) as typeof process.stderr.write + try { + const result = await invoke(["--version"]) + expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/) + } finally { + process.stdout.write = stdoutWrite + process.stderr.write = stderrWrite + } + expect(direct).toEqual([]) + }) + + it("installs no signal handlers", async () => { + const before = { + SIGINT: process.listenerCount("SIGINT"), + SIGTERM: process.listenerCount("SIGTERM"), + SIGHUP: process.listenerCount("SIGHUP") + } + await invoke(["--version"]) + expect({ + SIGINT: process.listenerCount("SIGINT"), + SIGTERM: process.listenerCount("SIGTERM"), + SIGHUP: process.listenerCount("SIGHUP") + }).toEqual(before) + }) + + it("never calls process.exit", async () => { + const realExit = process.exit + let exitCalls = 0 + process.exit = ((code?: number) => { + exitCalls += 1 + throw new Error(`process.exit(${code}) called`) + }) as typeof process.exit + try { + const result = await invoke(["--version"]) + expect(result.code).toBe(0) + } finally { + process.exit = realExit + } + expect(exitCalls).toBe(0) + }) +}) diff --git a/packages/sdk/typescript/src/relay-cli/testing/build-binary.ts b/packages/sdk/typescript/src/relay-cli/testing/build-binary.ts new file mode 100644 index 00000000..aea38007 --- /dev/null +++ b/packages/sdk/typescript/src/relay-cli/testing/build-binary.ts @@ -0,0 +1,74 @@ +/** + * Test support: build the real relayfile Go binary once per test run. + * + * The CLI-surface tests execute the actual binary — no stubs — so they need a + * built one. `go run` would recompile on every invocation; building once and + * pointing the resolver at the output keeps a per-command sweep fast. + * + * Excluded from the published build by tsconfig. + */ + +import { spawnSync } from "node:child_process" +import { existsSync, mkdirSync, mkdtempSync } from "node:fs" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" + +import { findSourceCheckoutRoot } from "../resolve-binary.js" + +let cached: { binDir: string; checkoutRoot: string } | undefined + +/** + * Locate the relayfile checkout these tests run inside. + * + * @returns The checkout root. + * @throws When the tests are not running inside a checkout. + */ +export function checkoutRoot(): string { + const root = findSourceCheckoutRoot(path.dirname(fileURLToPath(import.meta.url))) + if (!root) { + throw new Error( + "relay-cli tests must run inside a relayfile checkout (no go.mod + cmd/relayfile-cli found)" + ) + } + return root +} + +/** + * Build `cmd/relayfile-cli` into a temp directory shaped like the `relayfile` + * package's `bin/`, so it can be handed to `resolveRelayfileBinary` as a + * `binDirs` entry. + * + * @returns The directory holding the built binary, and the checkout root. + * @throws When the Go toolchain is missing or the build fails. + */ +export function buildRelayfileBinary(): { binDir: string; checkoutRoot: string } { + if (cached) { + return cached + } + + const root = checkoutRoot() + const binDir = path.join(mkdtempSync(path.join(os.tmpdir(), "relayfile-surface-")), "bin") + mkdirSync(binDir, { recursive: true }) + const output = path.join(binDir, os.platform() === "win32" ? "relayfile.exe" : "relayfile") + + const result = spawnSync("go", ["build", "-o", output, "./cmd/relayfile-cli"], { + cwd: root, + encoding: "utf8" + }) + if (result.error) { + const code = (result.error as NodeJS.ErrnoException).code + if (code === "ENOENT") { + throw new Error( + "the relay-cli surface tests execute the real relayfile binary and need a Go toolchain on PATH" + ) + } + throw result.error + } + if (result.status !== 0 || !existsSync(output)) { + throw new Error(`go build ./cmd/relayfile-cli exited ${result.status}\n${result.stderr}`) + } + + cached = { binDir, checkoutRoot: root } + return cached +} diff --git a/packages/sdk/typescript/tsconfig.json b/packages/sdk/typescript/tsconfig.json index 34478543..71ddebd5 100644 --- a/packages/sdk/typescript/tsconfig.json +++ b/packages/sdk/typescript/tsconfig.json @@ -9,6 +9,11 @@ "rootDir": "src", "skipLibCheck": true }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": [ + "src" + ], + "exclude": [ + "src/**/*.test.ts", + "src/**/testing/**" + ] } From 66a94f3bc0686152915a29835409d70dcc89d623 Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Thu, 17 Sep 2026 14:27:50 -0700 Subject: [PATCH 02/14] chore(trail): record relay-cli surface trajectory Co-Authored-By: Claude Opus 5 (1M context) Session-Id: 57ec71cd-46c6-41cf-8fb5-952f0cd36dab --- .trajectories/active/traj_jcnhoywu08ve.json | 46 -- .../completed/2026-09/traj_jcnhoywu08ve.json | 99 +++ .../completed/2026-09/traj_jcnhoywu08ve.md | 39 ++ .../2026-09/traj_jcnhoywu08ve.trace.json | 636 ++++++++++++++++++ .trajectories/index.json | 7 +- 5 files changed, 778 insertions(+), 49 deletions(-) delete mode 100644 .trajectories/active/traj_jcnhoywu08ve.json create mode 100644 .trajectories/completed/2026-09/traj_jcnhoywu08ve.json create mode 100644 .trajectories/completed/2026-09/traj_jcnhoywu08ve.md create mode 100644 .trajectories/completed/2026-09/traj_jcnhoywu08ve.trace.json diff --git a/.trajectories/active/traj_jcnhoywu08ve.json b/.trajectories/active/traj_jcnhoywu08ve.json deleted file mode 100644 index c89a9c70..00000000 --- a/.trajectories/active/traj_jcnhoywu08ve.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "id": "traj_jcnhoywu08ve", - "version": 1, - "task": { - "title": "Expose @relayfile/sdk/relay-cli CLI surface for agent-relay file" - }, - "status": "active", - "startedAt": "2026-09-17T21:00:11.799Z", - "agents": [ - { - "name": "default", - "role": "lead", - "joinedAt": "2026-09-17T21:23:08.808Z" - } - ], - "chapters": [ - { - "id": "chap_yz8wjtt5nove", - "title": "Work", - "agentName": "default", - "startedAt": "2026-09-17T21:23:08.808Z", - "events": [ - { - "ts": 1789680188809, - "type": "decision", - "content": "Declarative Go command table drives run() dispatch; AST drift tests for nested levels: Declarative Go command table drives run() dispatch; AST drift tests for nested levels", - "raw": { - "question": "Declarative Go command table drives run() dispatch; AST drift tests for nested levels", - "chosen": "Declarative Go command table drives run() dispatch; AST drift tests for nested levels", - "alternatives": [], - "reasoning": "relayfile's Go CLI is a hand-rolled flag dispatcher, not cobra, so there is no command tree to walk. Making the table the dispatcher removes top-level drift structurally; parsing the per-group switches and each leaf FlagSet out of the source AST catches nested drift without rewriting 14.7k lines." - }, - "significance": "high" - } - ] - } - ], - "commits": [], - "filesChanged": [], - "projectId": "AgentWorkforce/relayfile", - "tags": [], - "_trace": { - "startRef": "672260b97dc48963b6125a37f6531a28abbc9a90", - "endRef": "672260b97dc48963b6125a37f6531a28abbc9a90" - } -} \ No newline at end of file diff --git a/.trajectories/completed/2026-09/traj_jcnhoywu08ve.json b/.trajectories/completed/2026-09/traj_jcnhoywu08ve.json new file mode 100644 index 00000000..48a04404 --- /dev/null +++ b/.trajectories/completed/2026-09/traj_jcnhoywu08ve.json @@ -0,0 +1,99 @@ +{ + "id": "traj_jcnhoywu08ve", + "version": 1, + "task": { + "title": "Expose @relayfile/sdk/relay-cli CLI surface for agent-relay file" + }, + "status": "completed", + "startedAt": "2026-09-17T21:00:11.799Z", + "completedAt": "2026-09-17T21:27:43.737Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-09-17T21:23:08.808Z" + } + ], + "chapters": [ + { + "id": "chap_yz8wjtt5nove", + "title": "Work", + "agentName": "default", + "startedAt": "2026-09-17T21:23:08.808Z", + "endedAt": "2026-09-17T21:27:43.737Z", + "events": [ + { + "ts": 1789680188809, + "type": "decision", + "content": "Declarative Go command table drives run() dispatch; AST drift tests for nested levels: Declarative Go command table drives run() dispatch; AST drift tests for nested levels", + "raw": { + "question": "Declarative Go command table drives run() dispatch; AST drift tests for nested levels", + "chosen": "Declarative Go command table drives run() dispatch; AST drift tests for nested levels", + "alternatives": [], + "reasoning": "relayfile's Go CLI is a hand-rolled flag dispatcher, not cobra, so there is no command tree to walk. Making the table the dispatcher removes top-level drift structurally; parsing the per-group switches and each leaf FlagSet out of the source AST catches nested drift without rewriting 14.7k lines." + }, + "significance": "high" + }, + { + "ts": 1789680463417, + "type": "reflection", + "content": "Deliverable complete and pushed: SDK relay-cli surface, single-homed binary resolver + cloud preflight, Go command-spec emitter with AST drift tests, real-binary E2E. Awaiting lead decision on mount-with-args+subcommands and PR.", + "raw": { + "confidence": 0.85 + }, + "significance": "high", + "tags": [ + "confidence:0.85" + ] + } + ] + } + ], + "retrospective": { + "summary": "Added @relayfile/sdk/relay-cli CLI surface (23 top-level commands) for agent-relay file: declarative Go command table now drives run() dispatch and feeds a hidden __command-spec --json emitter snapshotted into the SDK; binary resolution and cloud preflight moved into the SDK as the single home, with run.js and install.js calling them; AST drift tests in Go, regenerate-and-diff snapshot test, and real-Go-binary E2E through surface.run()", + "approach": "Standard approach", + "confidence": 0.85 + }, + "commits": [ + "c4456003" + ], + "filesChanged": [ + ".trajectories/active/traj_jcnhoywu08ve.json", + ".trajectories/index.json", + "cmd/relayfile-cli/commandspec.go", + "cmd/relayfile-cli/commandspec_test.go", + "cmd/relayfile-cli/main.go", + "cmd/relayfile-cli/main_test.go", + "package-lock.json", + "packages/cli/CHANGELOG.md", + "packages/cli/package.json", + "packages/cli/scripts/cloud-auth.test.js", + "packages/cli/scripts/cloud-preflight.js", + "packages/cli/scripts/cloud-preflight.test.js", + "packages/cli/scripts/install.js", + "packages/cli/scripts/run.js", + "packages/cli/scripts/run.test.js", + "packages/sdk/typescript/CHANGELOG.md", + "packages/sdk/typescript/package.json", + "packages/sdk/typescript/scripts/copy-relay-cli-assets.mjs", + "packages/sdk/typescript/scripts/gen-command-spec.mjs", + "packages/sdk/typescript/src/import-safety.test.ts", + "packages/sdk/typescript/src/relay-cli/cloud-preflight.test.ts", + "packages/sdk/typescript/src/relay-cli/cloud-preflight.ts", + "packages/sdk/typescript/src/relay-cli/command-spec.json", + "packages/sdk/typescript/src/relay-cli/command-spec.test.ts", + "packages/sdk/typescript/src/relay-cli/index.ts", + "packages/sdk/typescript/src/relay-cli/resolve-binary.test.ts", + "packages/sdk/typescript/src/relay-cli/resolve-binary.ts", + "packages/sdk/typescript/src/relay-cli/surface.test.ts", + "packages/sdk/typescript/src/relay-cli/testing/build-binary.ts", + "packages/sdk/typescript/tsconfig.json" + ], + "projectId": "AgentWorkforce/relayfile", + "tags": [], + "_trace": { + "startRef": "672260b97dc48963b6125a37f6531a28abbc9a90", + "endRef": "c445600374ab7e31b48726d5814b48ad3ba6be65", + "traceId": "ad31b563-c747-4ace-9570-003fc77277b6" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-09/traj_jcnhoywu08ve.md b/.trajectories/completed/2026-09/traj_jcnhoywu08ve.md new file mode 100644 index 00000000..c7a1c877 --- /dev/null +++ b/.trajectories/completed/2026-09/traj_jcnhoywu08ve.md @@ -0,0 +1,39 @@ +# Trajectory: Expose @relayfile/sdk/relay-cli CLI surface for agent-relay file + +> **Status:** ✅ Completed +> **Confidence:** 85% +> **Started:** September 17, 2026 at 02:00 PM +> **Completed:** September 17, 2026 at 02:27 PM + +--- + +## Summary + +Added @relayfile/sdk/relay-cli CLI surface (23 top-level commands) for agent-relay file: declarative Go command table now drives run() dispatch and feeds a hidden __command-spec --json emitter snapshotted into the SDK; binary resolution and cloud preflight moved into the SDK as the single home, with run.js and install.js calling them; AST drift tests in Go, regenerate-and-diff snapshot test, and real-Go-binary E2E through surface.run() + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Declarative Go command table drives run() dispatch; AST drift tests for nested levels +- **Chose:** Declarative Go command table drives run() dispatch; AST drift tests for nested levels +- **Reasoning:** relayfile's Go CLI is a hand-rolled flag dispatcher, not cobra, so there is no command tree to walk. Making the table the dispatcher removes top-level drift structurally; parsing the per-group switches and each leaf FlagSet out of the source AST catches nested drift without rewriting 14.7k lines. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Declarative Go command table drives run() dispatch; AST drift tests for nested levels: Declarative Go command table drives run() dispatch; AST drift tests for nested levels +- Deliverable complete and pushed: SDK relay-cli surface, single-homed binary resolver + cloud preflight, Go command-spec emitter with AST drift tests, real-binary E2E. Awaiting lead decision on mount-with-args+subcommands and PR. + +--- + +## Artifacts + +**Commits:** c4456003 +**Files changed:** 30 diff --git a/.trajectories/completed/2026-09/traj_jcnhoywu08ve.trace.json b/.trajectories/completed/2026-09/traj_jcnhoywu08ve.trace.json new file mode 100644 index 00000000..d887d72b --- /dev/null +++ b/.trajectories/completed/2026-09/traj_jcnhoywu08ve.trace.json @@ -0,0 +1,636 @@ +{ + "version": "1.0.0", + "id": "ad31b563-c747-4ace-9570-003fc77277b6", + "timestamp": "2026-09-17T21:27:43.795Z", + "trajectory": "traj_jcnhoywu08ve", + "files": [ + { + "path": ".trajectories/active/traj_jcnhoywu08ve.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 46, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": ".trajectories/index.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 6, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 372, + "end_line": 383, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/commandspec.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 1025, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/commandspec_test.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 469, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/main.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 637, + "end_line": 656, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 857, + "end_line": 863, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 866, + "end_line": 872, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 915, + "end_line": 921, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 5611, + "end_line": 5633, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/main_test.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 405, + "end_line": 416, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "package-lock.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 25, + "end_line": 42, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 482, + "end_line": 491, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 4876, + "end_line": 4881, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 4897, + "end_line": 4902, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 4918, + "end_line": 4923, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 4939, + "end_line": 4944, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 4960, + "end_line": 4965, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 4981, + "end_line": 4986, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 5002, + "end_line": 5007, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 5023, + "end_line": 5028, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 5044, + "end_line": 5049, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 5065, + "end_line": 5070, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 5086, + "end_line": 5091, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 7633, + "end_line": 7641, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 8505, + "end_line": 8511, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/cli/CHANGELOG.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 6, + "end_line": 19, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/cli/package.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 34, + "end_line": 41, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/cli/scripts/cloud-auth.test.js", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 170, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/cli/scripts/cloud-preflight.js", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [] + } + ] + }, + { + "path": "packages/cli/scripts/cloud-preflight.test.js", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [] + } + ] + }, + { + "path": "packages/cli/scripts/install.js", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 35, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 56, + "end_line": 71, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 75, + "end_line": 90, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 93, + "end_line": 99, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/cli/scripts/run.js", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 12, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 17, + "end_line": 88, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/cli/scripts/run.test.js", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 61, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/CHANGELOG.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 6, + "end_line": 16, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/package.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 14, + "end_line": 23, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 50, + "end_line": 56, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 61, + "end_line": 69, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + }, + { + "start_line": 77, + "end_line": 83, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/scripts/copy-relay-cli-assets.mjs", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 19, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/scripts/gen-command-spec.mjs", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 126, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/import-safety.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 24, + "end_line": 34, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/cloud-preflight.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 315, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/cloud-preflight.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 429, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/command-spec.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 1636, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/command-spec.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 80, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/index.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 269, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/resolve-binary.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 169, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/resolve-binary.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 239, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/surface.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 211, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/testing/build-binary.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 74, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/tsconfig.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 9, + "end_line": 19, + "revision": "c445600374ab7e31b48726d5814b48ad3ba6be65" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.trajectories/index.json b/.trajectories/index.json index f242c8db..529f37d3 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-09-17T21:23:08.811Z", + "lastUpdated": "2026-09-17T21:27:43.856Z", "trajectories": { "traj_4pvrlmqfnzng": { "title": "Review PR #278 in AgentWorkforce/relayfile", @@ -375,9 +375,10 @@ }, "traj_jcnhoywu08ve": { "title": "Expose @relayfile/sdk/relay-cli CLI surface for agent-relay file", - "status": "active", + "status": "completed", "startedAt": "2026-09-17T21:00:11.799Z", - "path": "/home/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/active/traj_jcnhoywu08ve.json" + "completedAt": "2026-09-17T21:27:43.737Z", + "path": "/home/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/completed/2026-09/traj_jcnhoywu08ve.json" } } } \ No newline at end of file From af70448f1ee4064cb6c1b37893c71bcec94d0313 Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Thu, 17 Sep 2026 15:41:55 -0700 Subject: [PATCH 03/14] feat(cli-binaries): ship relayfile-cli as per-platform npm packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agent-relay file ` resolved no binary on a clean install. The relayfile CLI binary was only ever fetched by the `relayfile` package's postinstall, and `agent-relay` depends on `@relayfile/sdk`, not on `relayfile` — so nothing in its dependency chain installed one. The existing tests passed only because they ran inside this checkout, where a built binary and a Go toolchain are both already present. Ship the binary the way this repo already ships `relayfile-mount`: - Add `@relayfile/cli-{darwin,linux}-{arm64,x64}` and `@relayfile/cli-win32-{arm64,x64}`, mirroring `packages/mount-*` — same manifest shape, `os`/`cpu`, `files`, and `.gitignore` treatment. win32 is included because `packages/cli/scripts/build-binaries.js` already cross-compiles both Windows targets and the release attaches them; mount has no Windows build, the CLI does. - Make them `optionalDependencies` of `@relayfile/sdk`, pinned exactly. npm installs only the matching one: no postinstall, no install-time network, works offline and in CI, integrity from the registry. - `scripts/build-cli-npm-packages.mjs` fills them, mirroring `build-mount-npm-packages.mjs`; `publish.yml` releases them alongside the mount packages. `resolveRelayfileBinary` now searches: `RELAYFILE_CLI_BIN`, the platform package, the previous `binDirs` chain, `make build`/`make release` outputs in a checkout, `go run`, then `PATH`. The order is commented. The PATH step matches `relayfile-cli` only, never the generic `relayfile`, which on PATH is the npm bin shim that resolves through this module — scanning for it would recurse forever. When nothing resolves, the error names the `@relayfile/cli-*` package for the current platform and how the optional dependency goes missing, instead of a bare ENOENT. Mounted as a CLI surface that is reported through the host's `io` as exit 127 rather than thrown, since the contract says `run()` resolves to an exit code. Also pass the child's output through as raw bytes rather than UTF-8-decoded strings, so `export --format tar --output -` survives being mounted. `@agent-relay/cli-surface`'s `RelayCliIo` was widened to `string | Uint8Array` for exactly this case. Tests, none of which can pass on this checkout's built binary alone: - `clean-install.test.ts` assembles a directory shaped like a real npm install under the OS temp dir — the SDK and the platform package under `node_modules`, no `relayfile` package, no `go.mod` above it — and runs a probe with plain `node` and an empty PATH, so the SDK loads through its real `exports` map, the platform package is found by the real `require.resolve`, and the real Go binary is spawned. Covers the resolving case, the `--omit=optional` case, and the env override. - `platform-packages.test.ts` fails if the package directories, the SDK's optionalDependencies, the resolver's target table, either build script, or any of the eight places `publish.yml` needs them drift apart — so a package can neither be published without being buildable nor exist without being published. - `resolve-binary.test.ts` pins the full search order with every ambient input sealed off. - `mount-routing.test.ts` proves `mount` routes identically through the surface and a direct spawn for twelve argv shapes — both positionals, all four hidden subcommands, and both aliases — including that its exit 2 is the binary's, not the surface's unknown-command 2. - `binary-output.test.ts` pins byte-exact stdout, including bytes no UTF-8 decode survives. Verified from a packed tarball install (`npm pack` of both, installed into an empty directory, run with an empty PATH and no checkout above it): 23 commands, the binary resolved from `node_modules/@relayfile/cli-linux-x64/bin/relayfile-cli`, `--version` exited 0 printing 0.10.56. Removing that package yields exit 127 and the actionable message. Co-Authored-By: Claude Opus 5 (1M context) Session-Id: 4e63354c-d2b2-48a1-82e8-21a328d10f6b --- .github/workflows/publish.yml | 195 ++++++++++-- .gitignore | 6 + docs/releasing.md | 2 +- packages/cli-darwin-arm64/README.md | 16 + packages/cli-darwin-arm64/bin/.gitkeep | 0 packages/cli-darwin-arm64/package.json | 23 ++ packages/cli-darwin-x64/README.md | 16 + packages/cli-darwin-x64/bin/.gitkeep | 0 packages/cli-darwin-x64/package.json | 23 ++ packages/cli-linux-arm64/README.md | 16 + packages/cli-linux-arm64/bin/.gitkeep | 0 packages/cli-linux-arm64/package.json | 23 ++ packages/cli-linux-x64/README.md | 16 + packages/cli-linux-x64/bin/.gitkeep | 0 packages/cli-linux-x64/package.json | 23 ++ packages/cli-win32-arm64/README.md | 16 + packages/cli-win32-arm64/bin/.gitkeep | 0 packages/cli-win32-arm64/package.json | 23 ++ packages/cli-win32-x64/README.md | 16 + packages/cli-win32-x64/bin/.gitkeep | 0 packages/cli-win32-x64/package.json | 23 ++ packages/sdk/typescript/CHANGELOG.md | 5 + packages/sdk/typescript/package.json | 6 + .../src/relay-cli/binary-output.test.ts | 136 ++++++++ .../src/relay-cli/clean-install.test.ts | 258 +++++++++++++++ .../sdk/typescript/src/relay-cli/index.ts | 51 ++- .../src/relay-cli/mount-routing.test.ts | 176 +++++++++++ .../src/relay-cli/platform-packages.test.ts | 267 ++++++++++++++++ .../src/relay-cli/resolve-binary.test.ts | 282 ++++++++++++++++- .../src/relay-cli/resolve-binary.ts | 293 +++++++++++++++++- .../src/relay-cli/testing/build-binary.ts | 54 ++++ scripts/build-cli-npm-packages.mjs | 128 ++++++++ 32 files changed, 2043 insertions(+), 50 deletions(-) create mode 100644 packages/cli-darwin-arm64/README.md create mode 100644 packages/cli-darwin-arm64/bin/.gitkeep create mode 100644 packages/cli-darwin-arm64/package.json create mode 100644 packages/cli-darwin-x64/README.md create mode 100644 packages/cli-darwin-x64/bin/.gitkeep create mode 100644 packages/cli-darwin-x64/package.json create mode 100644 packages/cli-linux-arm64/README.md create mode 100644 packages/cli-linux-arm64/bin/.gitkeep create mode 100644 packages/cli-linux-arm64/package.json create mode 100644 packages/cli-linux-x64/README.md create mode 100644 packages/cli-linux-x64/bin/.gitkeep create mode 100644 packages/cli-linux-x64/package.json create mode 100644 packages/cli-win32-arm64/README.md create mode 100644 packages/cli-win32-arm64/bin/.gitkeep create mode 100644 packages/cli-win32-arm64/package.json create mode 100644 packages/cli-win32-x64/README.md create mode 100644 packages/cli-win32-x64/bin/.gitkeep create mode 100644 packages/cli-win32-x64/package.json create mode 100644 packages/sdk/typescript/src/relay-cli/binary-output.test.ts create mode 100644 packages/sdk/typescript/src/relay-cli/clean-install.test.ts create mode 100644 packages/sdk/typescript/src/relay-cli/mount-routing.test.ts create mode 100644 packages/sdk/typescript/src/relay-cli/platform-packages.test.ts create mode 100644 scripts/build-cli-npm-packages.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e3175371..a72d3bda 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,6 +20,12 @@ on: - mount-darwin-x64 - mount-linux-arm64 - mount-linux-x64 + - cli-darwin-arm64 + - cli-darwin-x64 + - cli-linux-arm64 + - cli-linux-x64 + - cli-win32-arm64 + - cli-win32-x64 default: "all" version: description: "Version bump type" @@ -96,7 +102,7 @@ jobs: run: | set -euo pipefail case "$PACKAGE_INPUT" in - all|core|sdk|client|agents|cli|file-observer|local-mount|mount-darwin-arm64|mount-darwin-x64|mount-linux-arm64|mount-linux-x64) + all|core|sdk|client|agents|cli|file-observer|local-mount|mount-darwin-arm64|mount-darwin-x64|mount-linux-arm64|mount-linux-x64|cli-darwin-arm64|cli-darwin-x64|cli-linux-arm64|cli-linux-x64|cli-win32-arm64|cli-win32-x64) RELEASE_PACKAGE="$PACKAGE_INPUT" ;; *) @@ -296,7 +302,13 @@ jobs: "packages/mount-darwin-arm64/package.json", "packages/mount-darwin-x64/package.json", "packages/mount-linux-arm64/package.json", - "packages/mount-linux-x64/package.json" + "packages/mount-linux-x64/package.json", + "packages/cli-darwin-arm64/package.json", + "packages/cli-darwin-x64/package.json", + "packages/cli-linux-arm64/package.json", + "packages/cli-linux-x64/package.json", + "packages/cli-win32-arm64/package.json", + "packages/cli-win32-x64/package.json" ]' if [ -n "$CUSTOM_VERSION" ]; then @@ -443,6 +455,24 @@ jobs: packages/mount-linux-x64/package.json packages/mount-linux-x64/README.md packages/mount-linux-x64/bin/.gitkeep + packages/cli-darwin-arm64/package.json + packages/cli-darwin-arm64/README.md + packages/cli-darwin-arm64/bin/.gitkeep + packages/cli-darwin-x64/package.json + packages/cli-darwin-x64/README.md + packages/cli-darwin-x64/bin/.gitkeep + packages/cli-linux-arm64/package.json + packages/cli-linux-arm64/README.md + packages/cli-linux-arm64/bin/.gitkeep + packages/cli-linux-x64/package.json + packages/cli-linux-x64/README.md + packages/cli-linux-x64/bin/.gitkeep + packages/cli-win32-arm64/package.json + packages/cli-win32-arm64/README.md + packages/cli-win32-arm64/bin/.gitkeep + packages/cli-win32-x64/package.json + packages/cli-win32-x64/README.md + packages/cli-win32-x64/bin/.gitkeep retention-days: 1 # Cross-compile relayfile-mount for every consumer-supported platform. @@ -550,36 +580,71 @@ jobs: - package: core path: packages/core mount_binary: "" + cli_binary: "" - package: sdk path: packages/sdk/typescript mount_binary: "" + cli_binary: "" - package: client path: packages/client mount_binary: "" + cli_binary: "" - package: agents path: packages/agents mount_binary: "" + cli_binary: "" - package: cli path: packages/cli mount_binary: "" + cli_binary: "" - package: file-observer path: packages/file-observer mount_binary: "" + cli_binary: "" - package: local-mount path: packages/local-mount mount_binary: "" + cli_binary: "" - package: mount-darwin-arm64 path: packages/mount-darwin-arm64 mount_binary: relayfile-mount-darwin-arm64 + cli_binary: "" - package: mount-darwin-x64 path: packages/mount-darwin-x64 mount_binary: relayfile-mount-darwin-amd64 + cli_binary: "" - package: mount-linux-arm64 path: packages/mount-linux-arm64 mount_binary: relayfile-mount-linux-arm64 + cli_binary: "" - package: mount-linux-x64 path: packages/mount-linux-x64 mount_binary: relayfile-mount-linux-amd64 + cli_binary: "" + - package: cli-darwin-arm64 + path: packages/cli-darwin-arm64 + mount_binary: "" + cli_binary: relayfile-cli-darwin-arm64 + - package: cli-darwin-x64 + path: packages/cli-darwin-x64 + mount_binary: "" + cli_binary: relayfile-cli-darwin-amd64 + - package: cli-linux-arm64 + path: packages/cli-linux-arm64 + mount_binary: "" + cli_binary: relayfile-cli-linux-arm64 + - package: cli-linux-x64 + path: packages/cli-linux-x64 + mount_binary: "" + cli_binary: relayfile-cli-linux-amd64 + - package: cli-win32-arm64 + path: packages/cli-win32-arm64 + mount_binary: "" + cli_binary: relayfile-cli-windows-arm64.exe + - package: cli-win32-x64 + path: packages/cli-win32-x64 + mount_binary: "" + cli_binary: relayfile-cli-windows-amd64.exe steps: - name: Checkout code @@ -607,7 +672,7 @@ jobs: path: . - name: Restore CLI binary permissions - if: matrix.package == 'cli' + if: matrix.package == 'cli' || matrix.cli_binary != '' run: chmod 755 packages/cli/bin/relayfile-cli-* - name: Download relayfile-mount binary @@ -625,6 +690,21 @@ jobs: cp "/tmp/relayfile-mount-binary/${{ matrix.mount_binary }}" "${{ matrix.path }}/bin/relayfile-mount" chmod 755 "${{ matrix.path }}/bin/relayfile-mount" + - name: Prepare cli platform package + if: matrix.cli_binary != '' + run: | + set -euo pipefail + # The build job already cross-compiled every relayfile-cli target + # into packages/cli/bin (packages/cli/scripts/build-binaries.js) and + # uploaded it in build-output, so there is nothing to download here. + # Runs on dry runs too: the binary is what makes the tarball real. + test -f "packages/cli/bin/${{ matrix.cli_binary }}" + mkdir -p "${{ matrix.path }}/bin" + BIN_NAME=relayfile-cli + case "${{ matrix.cli_binary }}" in *.exe) BIN_NAME=relayfile-cli.exe ;; esac + cp "packages/cli/bin/${{ matrix.cli_binary }}" "${{ matrix.path }}/bin/${BIN_NAME}" + chmod 755 "${{ matrix.path }}/bin/${BIN_NAME}" + - name: Update npm for OIDC support run: npm install -g npm@11 @@ -669,36 +749,71 @@ jobs: - package: core path: packages/core mount_binary: "" + cli_binary: "" - package: sdk path: packages/sdk/typescript mount_binary: "" + cli_binary: "" - package: client path: packages/client mount_binary: "" + cli_binary: "" - package: agents path: packages/agents mount_binary: "" + cli_binary: "" - package: cli path: packages/cli mount_binary: "" + cli_binary: "" - package: file-observer path: packages/file-observer mount_binary: "" + cli_binary: "" - package: local-mount path: packages/local-mount mount_binary: "" + cli_binary: "" - package: mount-darwin-arm64 path: packages/mount-darwin-arm64 mount_binary: relayfile-mount-darwin-arm64 + cli_binary: "" - package: mount-darwin-x64 path: packages/mount-darwin-x64 mount_binary: relayfile-mount-darwin-amd64 + cli_binary: "" - package: mount-linux-arm64 path: packages/mount-linux-arm64 mount_binary: relayfile-mount-linux-arm64 + cli_binary: "" - package: mount-linux-x64 path: packages/mount-linux-x64 mount_binary: relayfile-mount-linux-amd64 + cli_binary: "" + - package: cli-darwin-arm64 + path: packages/cli-darwin-arm64 + mount_binary: "" + cli_binary: relayfile-cli-darwin-arm64 + - package: cli-darwin-x64 + path: packages/cli-darwin-x64 + mount_binary: "" + cli_binary: relayfile-cli-darwin-amd64 + - package: cli-linux-arm64 + path: packages/cli-linux-arm64 + mount_binary: "" + cli_binary: relayfile-cli-linux-arm64 + - package: cli-linux-x64 + path: packages/cli-linux-x64 + mount_binary: "" + cli_binary: relayfile-cli-linux-amd64 + - package: cli-win32-arm64 + path: packages/cli-win32-arm64 + mount_binary: "" + cli_binary: relayfile-cli-windows-arm64.exe + - package: cli-win32-x64 + path: packages/cli-win32-x64 + mount_binary: "" + cli_binary: relayfile-cli-windows-amd64.exe steps: - name: Checkout code @@ -726,7 +841,7 @@ jobs: path: . - name: Restore CLI binary permissions - if: matrix.package == 'cli' + if: matrix.package == 'cli' || matrix.cli_binary != '' run: chmod 755 packages/cli/bin/relayfile-cli-* - name: Download relayfile-mount binary @@ -744,6 +859,21 @@ jobs: cp "/tmp/relayfile-mount-binary/${{ matrix.mount_binary }}" "${{ matrix.path }}/bin/relayfile-mount" chmod 755 "${{ matrix.path }}/bin/relayfile-mount" + - name: Prepare cli platform package + if: matrix.cli_binary != '' + run: | + set -euo pipefail + # The build job already cross-compiled every relayfile-cli target + # into packages/cli/bin (packages/cli/scripts/build-binaries.js) and + # uploaded it in build-output, so there is nothing to download here. + # Runs on dry runs too: the binary is what makes the tarball real. + test -f "packages/cli/bin/${{ matrix.cli_binary }}" + mkdir -p "${{ matrix.path }}/bin" + BIN_NAME=relayfile-cli + case "${{ matrix.cli_binary }}" in *.exe) BIN_NAME=relayfile-cli.exe ;; esac + cp "packages/cli/bin/${{ matrix.cli_binary }}" "${{ matrix.path }}/bin/${BIN_NAME}" + chmod 755 "${{ matrix.path }}/bin/${BIN_NAME}" + # Pinned to the npm 11 line (OIDC needs >= 11.5.1): @latest broke # 2026-07-13 when npm 12 dropped support for the runner's node 22.14. - name: Update npm for OIDC support @@ -817,8 +947,11 @@ jobs: name: build-output path: . + # Unconditional: packages/cli/bin always arrives in build-output, and + # both `cli` and every `cli--` package needs these + # executable. Which one is being released is only known after the next + # step, so this cannot be gated on it. - name: Restore CLI binary permissions - if: needs.build.outputs.release_package == 'cli' run: chmod 755 packages/cli/bin/relayfile-cli-* - name: Resolve package path @@ -827,17 +960,23 @@ jobs: RELEASE_PACKAGE: ${{ needs.build.outputs.release_package }} run: | case "$RELEASE_PACKAGE" in - core) echo "path=packages/core" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT" ;; - sdk) echo "path=packages/sdk/typescript" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT" ;; - client) echo "path=packages/client" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT" ;; - agents) echo "path=packages/agents" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT" ;; - cli) echo "path=packages/cli" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT" ;; - file-observer) echo "path=packages/file-observer" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT" ;; - local-mount) echo "path=packages/local-mount" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT" ;; - mount-darwin-arm64) echo "path=packages/mount-darwin-arm64" >> "$GITHUB_OUTPUT"; echo "mount_binary=relayfile-mount-darwin-arm64" >> "$GITHUB_OUTPUT" ;; - mount-darwin-x64) echo "path=packages/mount-darwin-x64" >> "$GITHUB_OUTPUT"; echo "mount_binary=relayfile-mount-darwin-amd64" >> "$GITHUB_OUTPUT" ;; - mount-linux-arm64) echo "path=packages/mount-linux-arm64" >> "$GITHUB_OUTPUT"; echo "mount_binary=relayfile-mount-linux-arm64" >> "$GITHUB_OUTPUT" ;; - mount-linux-x64) echo "path=packages/mount-linux-x64" >> "$GITHUB_OUTPUT"; echo "mount_binary=relayfile-mount-linux-amd64" >> "$GITHUB_OUTPUT" ;; + core) echo "path=packages/core" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT"; echo "cli_binary=" >> "$GITHUB_OUTPUT" ;; + sdk) echo "path=packages/sdk/typescript" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT"; echo "cli_binary=" >> "$GITHUB_OUTPUT" ;; + client) echo "path=packages/client" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT"; echo "cli_binary=" >> "$GITHUB_OUTPUT" ;; + agents) echo "path=packages/agents" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT"; echo "cli_binary=" >> "$GITHUB_OUTPUT" ;; + cli) echo "path=packages/cli" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT"; echo "cli_binary=" >> "$GITHUB_OUTPUT" ;; + file-observer) echo "path=packages/file-observer" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT"; echo "cli_binary=" >> "$GITHUB_OUTPUT" ;; + local-mount) echo "path=packages/local-mount" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT"; echo "cli_binary=" >> "$GITHUB_OUTPUT" ;; + mount-darwin-arm64) echo "path=packages/mount-darwin-arm64" >> "$GITHUB_OUTPUT"; echo "mount_binary=relayfile-mount-darwin-arm64" >> "$GITHUB_OUTPUT"; echo "cli_binary=" >> "$GITHUB_OUTPUT" ;; + mount-darwin-x64) echo "path=packages/mount-darwin-x64" >> "$GITHUB_OUTPUT"; echo "mount_binary=relayfile-mount-darwin-amd64" >> "$GITHUB_OUTPUT"; echo "cli_binary=" >> "$GITHUB_OUTPUT" ;; + mount-linux-arm64) echo "path=packages/mount-linux-arm64" >> "$GITHUB_OUTPUT"; echo "mount_binary=relayfile-mount-linux-arm64" >> "$GITHUB_OUTPUT"; echo "cli_binary=" >> "$GITHUB_OUTPUT" ;; + mount-linux-x64) echo "path=packages/mount-linux-x64" >> "$GITHUB_OUTPUT"; echo "mount_binary=relayfile-mount-linux-amd64" >> "$GITHUB_OUTPUT"; echo "cli_binary=" >> "$GITHUB_OUTPUT" ;; + cli-darwin-arm64) echo "path=packages/cli-darwin-arm64" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT"; echo "cli_binary=relayfile-cli-darwin-arm64" >> "$GITHUB_OUTPUT" ;; + cli-darwin-x64) echo "path=packages/cli-darwin-x64" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT"; echo "cli_binary=relayfile-cli-darwin-amd64" >> "$GITHUB_OUTPUT" ;; + cli-linux-arm64) echo "path=packages/cli-linux-arm64" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT"; echo "cli_binary=relayfile-cli-linux-arm64" >> "$GITHUB_OUTPUT" ;; + cli-linux-x64) echo "path=packages/cli-linux-x64" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT"; echo "cli_binary=relayfile-cli-linux-amd64" >> "$GITHUB_OUTPUT" ;; + cli-win32-arm64) echo "path=packages/cli-win32-arm64" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT"; echo "cli_binary=relayfile-cli-windows-arm64.exe" >> "$GITHUB_OUTPUT" ;; + cli-win32-x64) echo "path=packages/cli-win32-x64" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT"; echo "cli_binary=relayfile-cli-windows-amd64.exe" >> "$GITHUB_OUTPUT" ;; *) echo "Unsupported package: $RELEASE_PACKAGE" >&2 exit 1 @@ -859,6 +998,17 @@ jobs: cp "/tmp/relayfile-mount-binary/${{ steps.resolve-package.outputs.mount_binary }}" "${{ steps.resolve-package.outputs.path }}/bin/relayfile-mount" chmod 755 "${{ steps.resolve-package.outputs.path }}/bin/relayfile-mount" + - name: Prepare cli platform package + if: steps.resolve-package.outputs.cli_binary != '' + run: | + set -euo pipefail + test -f "packages/cli/bin/${{ steps.resolve-package.outputs.cli_binary }}" + mkdir -p "${{ steps.resolve-package.outputs.path }}/bin" + BIN_NAME=relayfile-cli + case "${{ steps.resolve-package.outputs.cli_binary }}" in *.exe) BIN_NAME=relayfile-cli.exe ;; esac + cp "packages/cli/bin/${{ steps.resolve-package.outputs.cli_binary }}" "${{ steps.resolve-package.outputs.path }}/bin/${BIN_NAME}" + chmod 755 "${{ steps.resolve-package.outputs.path }}/bin/${BIN_NAME}" + # Pinned to the npm 11 line (OIDC needs >= 11.5.1): @latest broke # 2026-07-13 when npm 12 dropped support for the runner's node 22.14. - name: Update npm for OIDC support @@ -966,7 +1116,7 @@ jobs: done chmod +x mount-binaries/relayfile-mount-* test -d package-attestations - test "$(find package-attestations -type f -name '*.json' | wc -l | tr -d ' ')" -eq 11 + test "$(find package-attestations -type f -name '*.json' | wc -l | tr -d ' ')" -eq 17 for BINARY in \ relayfile-mount-linux-amd64 \ relayfile-mount-linux-arm64 \ @@ -1031,7 +1181,10 @@ jobs: packages/file-observer/package.json packages/file-observer/CHANGELOG.md \ packages/local-mount/package.json packages/local-mount/CHANGELOG.md \ packages/mount-darwin-arm64/package.json packages/mount-darwin-x64/package.json \ - packages/mount-linux-arm64/package.json packages/mount-linux-x64/package.json + packages/mount-linux-arm64/package.json packages/mount-linux-x64/package.json \ + packages/cli-darwin-arm64/package.json packages/cli-darwin-x64/package.json \ + packages/cli-linux-arm64/package.json packages/cli-linux-x64/package.json \ + packages/cli-win32-arm64/package.json packages/cli-win32-x64/package.json if ! git diff --staged --quiet; then git commit -m "chore(release): v${NEW_VERSION}" fi @@ -1142,6 +1295,12 @@ jobs: - `@relayfile/mount-darwin-x64@${{ needs.build.outputs.new_version }}` - `@relayfile/mount-linux-arm64@${{ needs.build.outputs.new_version }}` - `@relayfile/mount-linux-x64@${{ needs.build.outputs.new_version }}` + - `@relayfile/cli-darwin-arm64@${{ needs.build.outputs.new_version }}` + - `@relayfile/cli-darwin-x64@${{ needs.build.outputs.new_version }}` + - `@relayfile/cli-linux-arm64@${{ needs.build.outputs.new_version }}` + - `@relayfile/cli-linux-x64@${{ needs.build.outputs.new_version }}` + - `@relayfile/cli-win32-arm64@${{ needs.build.outputs.new_version }}` + - `@relayfile/cli-win32-x64@${{ needs.build.outputs.new_version }}` ### Install ```bash diff --git a/.gitignore b/.gitignore index bd12c4bb..c4e6c278 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,12 @@ bin/ !packages/mount-*/bin/.gitkeep packages/mount-*/bin/relayfile-mount +# CLI platform packages: same arrangement as the mount packages above. +!packages/cli-*/bin/ +!packages/cli-*/bin/.gitkeep +packages/cli-*/bin/relayfile-cli +packages/cli-*/bin/relayfile-cli.exe + # Agent tool configs .factory/ .gemini/ diff --git a/docs/releasing.md b/docs/releasing.md index 503030ea..4dbc1784 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -16,7 +16,7 @@ gh workflow run publish.yml --ref main \ | Input | Notes | | --- | --- | -| `package` | Must be `all` for a real publish. The workflow hard-fails otherwise: it rewrites every manifest, and lockfile regeneration needs every `@relayfile/mount-*` package already published at the new version so npm can resolve tarball integrity metadata. | +| `package` | Must be `all` for a real publish. The workflow hard-fails otherwise: it rewrites every manifest, and lockfile regeneration needs every `@relayfile/mount-*` and `@relayfile/cli-*` package already published at the new version so npm can resolve tarball integrity metadata. | | `version` | `patch` / `minor` / `major`, or `prerelease` with `preid=rc` for a release candidate. | | `tag` | `latest` for stable, `next` for prereleases. | diff --git a/packages/cli-darwin-arm64/README.md b/packages/cli-darwin-arm64/README.md new file mode 100644 index 00000000..3208cd2c --- /dev/null +++ b/packages/cli-darwin-arm64/README.md @@ -0,0 +1,16 @@ +# @relayfile/cli-darwin-arm64 + +Prebuilt `relayfile-cli` binary (`bin/relayfile-cli`) for **macOS (Apple silicon)**. + +This package is installed automatically as an optional dependency of +[`@relayfile/sdk`](https://www.npmjs.com/package/@relayfile/sdk). You do not +need to depend on it directly. The SDK resolves the correct platform binary at +runtime via `require.resolve` (see `@relayfile/sdk/relay-cli`), which is how +both `relayfile ` and `agent-relay file ` find the CLI. + +It carries no install script and downloads nothing: npm installs only the +package matching the host's `os`/`cpu`, so the binary arrives with registry +integrity metadata and the install works offline and in CI. + +See the [relayfile repository](https://github.com/AgentWorkforce/relayfile) +for source and build tooling. diff --git a/packages/cli-darwin-arm64/bin/.gitkeep b/packages/cli-darwin-arm64/bin/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/cli-darwin-arm64/package.json b/packages/cli-darwin-arm64/package.json new file mode 100644 index 00000000..d19a7bf2 --- /dev/null +++ b/packages/cli-darwin-arm64/package.json @@ -0,0 +1,23 @@ +{ + "name": "@relayfile/cli-darwin-arm64", + "version": "0.10.56", + "description": "relayfile-cli binary for darwin arm64. Installed automatically as an optional dependency of @relayfile/sdk.", + "files": [ + "bin" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/AgentWorkforce/relayfile.git", + "directory": "packages/cli-darwin-arm64" + }, + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ] +} diff --git a/packages/cli-darwin-x64/README.md b/packages/cli-darwin-x64/README.md new file mode 100644 index 00000000..ca5b3620 --- /dev/null +++ b/packages/cli-darwin-x64/README.md @@ -0,0 +1,16 @@ +# @relayfile/cli-darwin-x64 + +Prebuilt `relayfile-cli` binary (`bin/relayfile-cli`) for **macOS (Intel)**. + +This package is installed automatically as an optional dependency of +[`@relayfile/sdk`](https://www.npmjs.com/package/@relayfile/sdk). You do not +need to depend on it directly. The SDK resolves the correct platform binary at +runtime via `require.resolve` (see `@relayfile/sdk/relay-cli`), which is how +both `relayfile ` and `agent-relay file ` find the CLI. + +It carries no install script and downloads nothing: npm installs only the +package matching the host's `os`/`cpu`, so the binary arrives with registry +integrity metadata and the install works offline and in CI. + +See the [relayfile repository](https://github.com/AgentWorkforce/relayfile) +for source and build tooling. diff --git a/packages/cli-darwin-x64/bin/.gitkeep b/packages/cli-darwin-x64/bin/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/cli-darwin-x64/package.json b/packages/cli-darwin-x64/package.json new file mode 100644 index 00000000..fe661ee7 --- /dev/null +++ b/packages/cli-darwin-x64/package.json @@ -0,0 +1,23 @@ +{ + "name": "@relayfile/cli-darwin-x64", + "version": "0.10.56", + "description": "relayfile-cli binary for darwin x64. Installed automatically as an optional dependency of @relayfile/sdk.", + "files": [ + "bin" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/AgentWorkforce/relayfile.git", + "directory": "packages/cli-darwin-x64" + }, + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ] +} diff --git a/packages/cli-linux-arm64/README.md b/packages/cli-linux-arm64/README.md new file mode 100644 index 00000000..ba861d47 --- /dev/null +++ b/packages/cli-linux-arm64/README.md @@ -0,0 +1,16 @@ +# @relayfile/cli-linux-arm64 + +Prebuilt `relayfile-cli` binary (`bin/relayfile-cli`) for **Linux (arm64)**. + +This package is installed automatically as an optional dependency of +[`@relayfile/sdk`](https://www.npmjs.com/package/@relayfile/sdk). You do not +need to depend on it directly. The SDK resolves the correct platform binary at +runtime via `require.resolve` (see `@relayfile/sdk/relay-cli`), which is how +both `relayfile ` and `agent-relay file ` find the CLI. + +It carries no install script and downloads nothing: npm installs only the +package matching the host's `os`/`cpu`, so the binary arrives with registry +integrity metadata and the install works offline and in CI. + +See the [relayfile repository](https://github.com/AgentWorkforce/relayfile) +for source and build tooling. diff --git a/packages/cli-linux-arm64/bin/.gitkeep b/packages/cli-linux-arm64/bin/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/cli-linux-arm64/package.json b/packages/cli-linux-arm64/package.json new file mode 100644 index 00000000..257e60c2 --- /dev/null +++ b/packages/cli-linux-arm64/package.json @@ -0,0 +1,23 @@ +{ + "name": "@relayfile/cli-linux-arm64", + "version": "0.10.56", + "description": "relayfile-cli binary for linux arm64. Installed automatically as an optional dependency of @relayfile/sdk.", + "files": [ + "bin" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/AgentWorkforce/relayfile.git", + "directory": "packages/cli-linux-arm64" + }, + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ] +} diff --git a/packages/cli-linux-x64/README.md b/packages/cli-linux-x64/README.md new file mode 100644 index 00000000..71e15b80 --- /dev/null +++ b/packages/cli-linux-x64/README.md @@ -0,0 +1,16 @@ +# @relayfile/cli-linux-x64 + +Prebuilt `relayfile-cli` binary (`bin/relayfile-cli`) for **Linux (x64)**. + +This package is installed automatically as an optional dependency of +[`@relayfile/sdk`](https://www.npmjs.com/package/@relayfile/sdk). You do not +need to depend on it directly. The SDK resolves the correct platform binary at +runtime via `require.resolve` (see `@relayfile/sdk/relay-cli`), which is how +both `relayfile ` and `agent-relay file ` find the CLI. + +It carries no install script and downloads nothing: npm installs only the +package matching the host's `os`/`cpu`, so the binary arrives with registry +integrity metadata and the install works offline and in CI. + +See the [relayfile repository](https://github.com/AgentWorkforce/relayfile) +for source and build tooling. diff --git a/packages/cli-linux-x64/bin/.gitkeep b/packages/cli-linux-x64/bin/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/cli-linux-x64/package.json b/packages/cli-linux-x64/package.json new file mode 100644 index 00000000..4271391c --- /dev/null +++ b/packages/cli-linux-x64/package.json @@ -0,0 +1,23 @@ +{ + "name": "@relayfile/cli-linux-x64", + "version": "0.10.56", + "description": "relayfile-cli binary for linux x64. Installed automatically as an optional dependency of @relayfile/sdk.", + "files": [ + "bin" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/AgentWorkforce/relayfile.git", + "directory": "packages/cli-linux-x64" + }, + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ] +} diff --git a/packages/cli-win32-arm64/README.md b/packages/cli-win32-arm64/README.md new file mode 100644 index 00000000..49e83fcc --- /dev/null +++ b/packages/cli-win32-arm64/README.md @@ -0,0 +1,16 @@ +# @relayfile/cli-win32-arm64 + +Prebuilt `relayfile-cli` binary (`bin/relayfile-cli.exe`) for **Windows (arm64)**. + +This package is installed automatically as an optional dependency of +[`@relayfile/sdk`](https://www.npmjs.com/package/@relayfile/sdk). You do not +need to depend on it directly. The SDK resolves the correct platform binary at +runtime via `require.resolve` (see `@relayfile/sdk/relay-cli`), which is how +both `relayfile ` and `agent-relay file ` find the CLI. + +It carries no install script and downloads nothing: npm installs only the +package matching the host's `os`/`cpu`, so the binary arrives with registry +integrity metadata and the install works offline and in CI. + +See the [relayfile repository](https://github.com/AgentWorkforce/relayfile) +for source and build tooling. diff --git a/packages/cli-win32-arm64/bin/.gitkeep b/packages/cli-win32-arm64/bin/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/cli-win32-arm64/package.json b/packages/cli-win32-arm64/package.json new file mode 100644 index 00000000..68225d7d --- /dev/null +++ b/packages/cli-win32-arm64/package.json @@ -0,0 +1,23 @@ +{ + "name": "@relayfile/cli-win32-arm64", + "version": "0.10.56", + "description": "relayfile-cli binary for win32 arm64. Installed automatically as an optional dependency of @relayfile/sdk.", + "files": [ + "bin" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/AgentWorkforce/relayfile.git", + "directory": "packages/cli-win32-arm64" + }, + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ] +} diff --git a/packages/cli-win32-x64/README.md b/packages/cli-win32-x64/README.md new file mode 100644 index 00000000..f701a2d8 --- /dev/null +++ b/packages/cli-win32-x64/README.md @@ -0,0 +1,16 @@ +# @relayfile/cli-win32-x64 + +Prebuilt `relayfile-cli` binary (`bin/relayfile-cli.exe`) for **Windows (x64)**. + +This package is installed automatically as an optional dependency of +[`@relayfile/sdk`](https://www.npmjs.com/package/@relayfile/sdk). You do not +need to depend on it directly. The SDK resolves the correct platform binary at +runtime via `require.resolve` (see `@relayfile/sdk/relay-cli`), which is how +both `relayfile ` and `agent-relay file ` find the CLI. + +It carries no install script and downloads nothing: npm installs only the +package matching the host's `os`/`cpu`, so the binary arrives with registry +integrity metadata and the install works offline and in CI. + +See the [relayfile repository](https://github.com/AgentWorkforce/relayfile) +for source and build tooling. diff --git a/packages/cli-win32-x64/bin/.gitkeep b/packages/cli-win32-x64/bin/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/cli-win32-x64/package.json b/packages/cli-win32-x64/package.json new file mode 100644 index 00000000..46ad1bb9 --- /dev/null +++ b/packages/cli-win32-x64/package.json @@ -0,0 +1,23 @@ +{ + "name": "@relayfile/cli-win32-x64", + "version": "0.10.56", + "description": "relayfile-cli binary for win32 x64. Installed automatically as an optional dependency of @relayfile/sdk.", + "files": [ + "bin" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/AgentWorkforce/relayfile.git", + "directory": "packages/cli-win32-x64" + }, + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ] +} diff --git a/packages/sdk/typescript/CHANGELOG.md b/packages/sdk/typescript/CHANGELOG.md index 854735c3..27912b33 100644 --- a/packages/sdk/typescript/CHANGELOG.md +++ b/packages/sdk/typescript/CHANGELOG.md @@ -10,9 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New `@relayfile/sdk/relay-cli` subpath export: `createRelayCliSurface()` returns relayfile's mountable CLI surface (`id: 'relayfile'`, contract v1), which the `agent-relay` CLI mounts as `agent-relay file`. `commands` is a checked-in snapshot of the Go CLI's own command table (`relayfile __command-spec --json`, regenerated by `npm run gen:command-spec`), and `run(argv, io)` spawns the same Go binary `relayfile` does, returning its real exit code. No relayfile command is reimplemented. - Binary resolution (`resolveRelayfileBinary`, `platformBinaryName`, `genericBinaryName`, `findSourceCheckoutRoot`) and the Cloud sign-in preflight (`prepareCloudSession`, `announceSetupIntent`) now live here, moved out of the `relayfile` package's scripts. Both the `relayfile` bin shim and `agent-relay file` use these, so each exists once in the repo. +- The `relayfile-cli` binary now ships as per-platform optional dependencies — `@relayfile/cli-darwin-arm64`, `@relayfile/cli-darwin-x64`, `@relayfile/cli-linux-arm64`, `@relayfile/cli-linux-x64`, `@relayfile/cli-win32-arm64`, `@relayfile/cli-win32-x64` — matching the existing `@relayfile/mount-*` packages. npm installs only the one matching the host, so there is no install-time download: installs work offline and in CI, and integrity comes from the registry. `resolveRelayfileBinary` looks there first, then falls back to the previous chain (`RELAYFILE_CLI_BIN`, the `relayfile` package's `bin/`, a source-checkout build, `go run`, `PATH`). ### Fixed +- A consumer that depends on `@relayfile/sdk` without also depending on `relayfile` could not resolve the CLI binary at all, because only the `relayfile` package's `postinstall` fetched it. `agent-relay file ` failed on a clean `npm i -g agent-relay` for exactly this reason. The per-platform packages above close that gap. +- When no binary can be found, the error now names the `@relayfile/cli-*` package to install for the current platform and how the optional dependency goes missing, instead of surfacing a bare `ENOENT`. Mounted as a CLI surface this returns exit code 127 through the host's `io` rather than throwing. +- `run(argv, io)` now passes the binary's output through as raw bytes instead of UTF-8-decoded strings, so `relayfile export --format tar --output -` survives being mounted as a CLI surface. + - Resolve the mount binary release version from bundled package metadata so compiled Bun consumers can start the SDK without a package-tree lookup. ## [0.10.56] - 2026-09-08 diff --git a/packages/sdk/typescript/package.json b/packages/sdk/typescript/package.json index 9cf2d0fc..4de079f9 100644 --- a/packages/sdk/typescript/package.json +++ b/packages/sdk/typescript/package.json @@ -71,6 +71,12 @@ "tar": "^7.5.10" }, "optionalDependencies": { + "@relayfile/cli-darwin-arm64": "0.10.56", + "@relayfile/cli-darwin-x64": "0.10.56", + "@relayfile/cli-linux-arm64": "0.10.56", + "@relayfile/cli-linux-x64": "0.10.56", + "@relayfile/cli-win32-arm64": "0.10.56", + "@relayfile/cli-win32-x64": "0.10.56", "@relayfile/mount-darwin-arm64": "0.10.56", "@relayfile/mount-darwin-x64": "0.10.56", "@relayfile/mount-linux-arm64": "0.10.56", diff --git a/packages/sdk/typescript/src/relay-cli/binary-output.test.ts b/packages/sdk/typescript/src/relay-cli/binary-output.test.ts new file mode 100644 index 00000000..79bbb4d9 --- /dev/null +++ b/packages/sdk/typescript/src/relay-cli/binary-output.test.ts @@ -0,0 +1,136 @@ +import { chmodSync, mkdirSync, writeFileSync } from "node:fs" +import { spawnSync } from "node:child_process" +import path from "node:path" + +import { beforeAll, describe, expect, it } from "vitest" + +import { createRelayCliSurface } from "./index.js" +import { buildRelayfileBinary, temporaryDirectory } from "./testing/build-binary.js" + +/** + * `relayfile export --format tar --output -` streams a tar archive to stdout. + * Mounted as a CLI surface, that output crosses an `io.stdout(chunk)` boundary, + * and decoding the chunks as UTF-8 destroys the archive without any error: + * every byte that is not valid UTF-8 comes out as U+FFFD. The same decode + * corrupts ordinary text when a multibyte character lands across a chunk + * boundary. + * + * So the surface hands `io` the raw bytes. These tests pin that. The archive + * itself needs a live workspace and a server, which a unit test cannot have — + * what is checked here instead is the plumbing that carries it: a real spawn + * through the real resolver, with a fixture binary as the payload producer, + * emitting bytes no UTF-8 decode survives. The text half is then checked + * against the real relayfile binary. + */ + +/** Bytes chosen to fail under any UTF-8 decode, plus a split multibyte char. */ +const INVALID_UTF8 = Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0xff, 0xfe, 0x00, 0x80]) +const MULTIBYTE = Buffer.from("héllo — ✅", "utf8") + +let realBinDir: string + +beforeAll(() => { + realBinDir = buildRelayfileBinary().binDir + // 3 minutes: a cold `go build` of cmd/relayfile-cli. +}, 180_000) + +/** + * A `bin/` directory holding a fake `relayfile` that writes fixed bytes to + * stdout, so a payload the real binary can only produce against a live server + * can still be pushed through the surface's stdio path. + * + * @param chunks - Byte chunks to write, one write each. + * @returns The bin directory to hand `resolveRelayfileBinary`. + */ +function fixtureBinDir(chunks: readonly Buffer[]): string { + const binDir = path.join(temporaryDirectory("binary-output"), "bin") + mkdirSync(binDir, { recursive: true }) + const script = path.join(binDir, "emit.mjs") + writeFileSync( + script, + `const chunks = ${JSON.stringify(chunks.map((chunk) => chunk.toString("base64")))} +for (const chunk of chunks) { + process.stdout.write(Buffer.from(chunk, "base64")) +} +process.exitCode = 0 +` + ) + const shim = path.join(binDir, "relayfile") + writeFileSync( + shim, + `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(script)}\n` + ) + chmodSync(shim, 0o755) + return binDir +} + +interface Capture { + code: number + stdout: Buffer + stderr: Buffer +} + +async function invoke(binDir: string, argv: readonly string[]): Promise { + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + const code = await createRelayCliSurface({ + resolve: { binDirs: [binDir] }, + skipCloudPreflight: true + }).run(argv, { + stdout: (chunk) => { + stdout.push(Buffer.from(chunk as Uint8Array)) + }, + stderr: (chunk) => { + stderr.push(Buffer.from(chunk as Uint8Array)) + } + }) + return { code, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) } +} + +describe("stdout is byte-exact", () => { + it.skipIf(process.platform === "win32")( + "carries bytes no UTF-8 decode would survive", + async () => { + const binDir = fixtureBinDir([INVALID_UTF8]) + const result = await invoke(binDir, ["export", "--format", "tar", "--output", "-"]) + + expect(result.code).toBe(0) + expect(result.stdout.equals(INVALID_UTF8)).toBe(true) + // What the old string sink produced instead: replacement characters. + expect(result.stdout.includes(Buffer.from("�", "utf8"))).toBe(false) + }, + 60_000 + ) + + it.skipIf(process.platform === "win32")( + "keeps a multibyte character intact across two writes", + async () => { + // Split mid-character: a per-chunk decode turns each half into U+FFFD. + const split = MULTIBYTE.length - 1 + const binDir = fixtureBinDir([ + MULTIBYTE.subarray(0, split), + MULTIBYTE.subarray(split) + ]) + const result = await invoke(binDir, ["read", "/x"]) + + expect(result.stdout.equals(MULTIBYTE)).toBe(true) + expect(result.stdout.toString("utf8")).toBe(MULTIBYTE.toString("utf8")) + }, + 60_000 + ) + + it("matches the real binary's own stdout byte for byte", async () => { + // The real thing, not a fixture: `--help` output through the surface must + // be identical to spawning the binary directly. + const argv = ["export", "--help"] + const throughSurface = await invoke(realBinDir, argv) + const direct = spawnSync( + path.join(realBinDir, process.platform === "win32" ? "relayfile.exe" : "relayfile"), + argv + ) + + expect(throughSurface.code).toBe(direct.status) + expect(throughSurface.stdout.equals(direct.stdout)).toBe(true) + expect(throughSurface.stderr.equals(direct.stderr)).toBe(true) + }, 60_000) +}) diff --git a/packages/sdk/typescript/src/relay-cli/clean-install.test.ts b/packages/sdk/typescript/src/relay-cli/clean-install.test.ts new file mode 100644 index 00000000..5da88109 --- /dev/null +++ b/packages/sdk/typescript/src/relay-cli/clean-install.test.ts @@ -0,0 +1,258 @@ +import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { spawnSync } from "node:child_process" +import path from "node:path" + +import { beforeAll, describe, expect, it } from "vitest" + +import { RELAY_CLI_EXIT_BINARY_NOT_FOUND } from "./index.js" +import { + buildRelayfileBinary, + buildSdkDist, + checkoutRoot, + temporaryDirectory +} from "./testing/build-binary.js" + +/** + * `agent-relay file ` shipped once resolving no binary at all on a clean + * machine: `agent-relay` depends on `@relayfile/sdk`, never on `relayfile`, so + * the `relayfile` package's postinstall download never ran for it. The tests + * that were supposed to catch that passed anyway, because they ran inside this + * checkout where a built binary and a Go toolchain are both sitting right + * there. + * + * These tests remove that blind spot. Each one builds a directory shaped like + * a real `npm install` result — the SDK and the platform package under + * `node_modules`, nothing else — under the OS temp dir, where there is no + * `go.mod` above it and no `relayfile` package anywhere. A probe script is + * then run with plain `node`, so the SDK is loaded by package name through its + * real `exports` map, the platform package is found by the real + * `require.resolve`, and the real Go binary is spawned. Nothing is stubbed, and + * nothing about the host checkout can make them pass. + */ + +const root = checkoutRoot() +const sdkRoot = path.join(root, "packages", "sdk", "typescript") + +interface CleanInstall { + /** Root of the fake install; the probe runs with this as cwd. */ + directory: string + /** The `@relayfile/cli--` package directory inside it. */ + platformPackageDir: string +} + +let relayfileBinary: string + +beforeAll(() => { + buildSdkDist() + const built = buildRelayfileBinary() + relayfileBinary = path.join( + built.binDir, + process.platform === "win32" ? "relayfile.exe" : "relayfile" + ) + // 4 minutes: a cold `tsc` plus a cold `go build` on a clean module cache. +}, 240_000) + +/** + * Assemble a directory that looks like a consumer's `node_modules` after + * `npm install @relayfile/sdk`. + * + * @param label - Included in the temp directory name. + * @param options - `withPlatformPackage: false` simulates `--omit=optional`. + * @returns Paths inside the assembled tree. + */ +function cleanInstall( + label: string, + options: { withPlatformPackage?: boolean } = {} +): CleanInstall { + const directory = temporaryDirectory(`clean-install-${label}`) + const modules = path.join(directory, "node_modules", "@relayfile") + + // The SDK, exactly as published: manifest plus dist. Its `exports` map is + // what makes `import("@relayfile/sdk/relay-cli")` resolve. + const sdkTarget = path.join(modules, "sdk") + mkdirSync(sdkTarget, { recursive: true }) + cpSync(path.join(sdkRoot, "package.json"), path.join(sdkTarget, "package.json")) + cpSync(path.join(sdkRoot, "dist"), path.join(sdkTarget, "dist"), { recursive: true }) + + const platformPackageDir = path.join( + modules, + `cli-${process.platform}-${process.arch}` + ) + mkdirSync(path.join(platformPackageDir, "bin"), { recursive: true }) + writeFileSync( + path.join(platformPackageDir, "package.json"), + JSON.stringify( + { + name: `@relayfile/cli-${process.platform}-${process.arch}`, + version: "0.0.0-clean-install", + files: ["bin"], + os: [process.platform], + cpu: [process.arch] + }, + null, + 2 + ) + ) + if (options.withPlatformPackage !== false) { + cpSync( + relayfileBinary, + path.join( + platformPackageDir, + "bin", + process.platform === "win32" ? "relayfile-cli.exe" : "relayfile-cli" + ) + ) + } + + writeFileSync( + path.join(directory, "package.json"), + JSON.stringify({ name: "clean-install-consumer", private: true }, null, 2) + ) + + // No go.mod and no cmd/relayfile-cli above the temp dir, so neither the + // source-checkout step nor the `go run` fallback can rescue a failed lookup. + expect(existsSync(path.join(directory, "go.mod"))).toBe(false) + expect(existsSync(path.join(directory, "node_modules", "relayfile"))).toBe(false) + + return { directory, platformPackageDir } +} + +interface ProbeResult { + code: number + stdout: string + stderr: string + binaryPath?: string + failed?: string +} + +/** + * Run the surface inside a clean install and report what it did. + * + * `PATH` is emptied for the probe so the last-resort PATH scan cannot find a + * binary this machine happens to have, and `RELAYFILE_CLI_BIN` is cleared so + * an ambient override cannot mask a resolution failure. + * + * @param install - The tree to run in. + * @param argv - Arguments for the surface. + * @returns The surface's exit code and captured output. + */ +function probe(install: CleanInstall, argv: readonly string[]): ProbeResult { + const script = path.join(install.directory, "probe.mjs") + writeFileSync( + script, + `import { createRelayCliSurface, resolveRelayfileBinary } from "@relayfile/sdk/relay-cli" + +const argv = JSON.parse(process.argv[2]) +let stdout = "" +let stderr = "" +const surface = createRelayCliSurface({ skipCloudPreflight: true }) +const code = await surface.run(argv, { + stdout: (chunk) => { + stdout += chunk + }, + stderr: (chunk) => { + stderr += chunk + } +}) + +let binaryPath +let failed +try { + const resolution = resolveRelayfileBinary() + binaryPath = resolution.kind === "binary" ? resolution.binaryPath : resolution.command +} catch (error) { + failed = error.message +} + +process.stderr.write("<>" + JSON.stringify({ code, stdout, stderr, binaryPath, failed })) +` + ) + + const result = spawnSync(process.execPath, [script, JSON.stringify(argv)], { + cwd: install.directory, + encoding: "utf8", + // A bare env: no PATH for the fallback scan, no RELAYFILE_CLI_BIN override. + env: { PATH: "", HOME: install.directory } + }) + if (result.error) { + throw result.error + } + const marker = (result.stderr ?? "").indexOf("<>") + if (marker === -1) { + throw new Error( + `probe produced no result (exit ${result.status})\n${result.stdout}\n${result.stderr}` + ) + } + return JSON.parse(result.stderr.slice(marker + "<>".length)) as ProbeResult +} + +describe("clean install of @relayfile/sdk", () => { + it("runs the real binary out of the platform package", () => { + const install = cleanInstall("resolves") + const result = probe(install, ["--version"]) + + expect(result.failed).toBeUndefined() + expect(result.binaryPath).toBe( + path.join( + install.platformPackageDir, + "bin", + process.platform === "win32" ? "relayfile-cli.exe" : "relayfile-cli" + ) + ) + // The real Go binary printed its real version and returned its real code. + expect(result.code).toBe(0) + expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/) + expect(result.stderr).toBe("") + }, 60_000) + + it("dispatches a real command through the platform package binary", () => { + const install = cleanInstall("dispatch") + const result = probe(install, ["export", "--help"]) + + expect(result.code).toBe(0) + expect(result.stdout).toContain("--format") + }, 60_000) + + it("tells the user what to install when the platform package is absent", () => { + // What `npm install --omit=optional` leaves behind, and what shipped + // before these packages existed. + const install = cleanInstall("omit-optional", { withPlatformPackage: false }) + rmSync(install.platformPackageDir, { recursive: true, force: true }) + + const result = probe(install, ["--version"]) + + expect(result.code).toBe(RELAY_CLI_EXIT_BINARY_NOT_FOUND) + expect(result.stdout).toBe("") + expect(result.stderr).toContain( + `@relayfile/cli-${process.platform}-${process.arch}` + ) + expect(result.stderr).toContain("--include=optional") + expect(result.stderr).toContain("RELAYFILE_CLI_BIN") + // Not a bare ENOENT, and not a stack trace at the host. + expect(result.stderr).not.toContain("ENOENT") + expect(result.failed).toContain( + `@relayfile/cli-${process.platform}-${process.arch}` + ) + }, 60_000) + + it("still resolves when only RELAYFILE_CLI_BIN is set", () => { + const install = cleanInstall("env-override", { withPlatformPackage: false }) + rmSync(install.platformPackageDir, { recursive: true, force: true }) + + const script = path.join(install.directory, "override.mjs") + writeFileSync( + script, + `import { resolveRelayfileBinary } from "@relayfile/sdk/relay-cli" +const resolution = resolveRelayfileBinary() +process.stdout.write(resolution.binaryPath) +` + ) + const result = spawnSync(process.execPath, [script], { + cwd: install.directory, + encoding: "utf8", + env: { PATH: "", HOME: install.directory, RELAYFILE_CLI_BIN: relayfileBinary } + }) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toBe(relayfileBinary) + }, 60_000) +}) diff --git a/packages/sdk/typescript/src/relay-cli/index.ts b/packages/sdk/typescript/src/relay-cli/index.ts index c594d763..cdada308 100644 --- a/packages/sdk/typescript/src/relay-cli/index.ts +++ b/packages/sdk/typescript/src/relay-cli/index.ts @@ -26,7 +26,9 @@ import { } from "./cloud-preflight.js" import { GO_TOOLCHAIN_MISSING_MESSAGE, + RelayfileBinaryNotFoundError, resolveRelayfileBinary, + type RelayfileBinaryResolution, type ResolveRelayfileBinaryOptions } from "./resolve-binary.js" @@ -36,9 +38,26 @@ export const RELAY_CLI_CONTRACT_VERSION = 1 /** Exit code for an argv the surface cannot route. */ export const RELAY_CLI_EXIT_UNKNOWN_COMMAND = 2 +/** + * Exit code when no relayfile binary could be found. Distinct from an + * unroutable argv (2) and from any code the binary itself returns, so a host + * can tell "relayfile is not installed here" from "relayfile ran and failed". + */ +export const RELAY_CLI_EXIT_BINARY_NOT_FOUND = 127 + +/** + * Output sink supplied by the host. + * + * Chunks are handed over as the raw bytes the binary wrote, not as decoded + * strings. `relayfile export --format tar --output -` streams a tar archive to + * stdout, and decoding that as UTF-8 corrupts it silently; a multibyte + * character split across two reads corrupts the same way in the other + * direction. Passing the bytes through untouched is correct for both, and + * matches `RelayCliIo` in `@agent-relay/cli-surface`. + */ export interface RelayCliIo { - stdout(chunk: string): void - stderr(chunk: string): void + stdout(chunk: string | Uint8Array): void + stderr(chunk: string | Uint8Array): void } export interface RelayCliArgSpec { @@ -196,7 +215,20 @@ export function createRelayCliSurface( }) } - const resolution = resolveRelayfileBinary(options.resolve) + let resolution: RelayfileBinaryResolution + try { + resolution = resolveRelayfileBinary(options.resolve) + } catch (error) { + if (!(error instanceof RelayfileBinaryNotFoundError)) { + throw error + } + // The contract says run() resolves to an exit code, so a missing + // binary is reported through io rather than thrown at the host. The + // message names the `@relayfile/cli-*` package to install for this + // platform; a bare ENOENT would send people hunting their PATH. + io.stderr(`${error.message}\n`) + return RELAY_CLI_EXIT_BINARY_NOT_FOUND + } const command = resolution.command const childArgs = [...resolution.args, ...args] const cwd = resolution.kind === "go-run" ? resolution.cwd : options.cwd @@ -212,10 +244,10 @@ export function createRelayCliSurface( stdio: ["inherit", "pipe", "pipe"] }) - child.stdout?.setEncoding("utf8") - child.stderr?.setEncoding("utf8") - child.stdout?.on("data", (chunk: string) => io.stdout(chunk)) - child.stderr?.on("data", (chunk: string) => io.stderr(chunk)) + // No setEncoding: the bytes go to `io` exactly as the binary wrote + // them. `export --format tar --output -` streams an archive here. + child.stdout?.on("data", (chunk: Buffer) => io.stdout(chunk)) + child.stderr?.on("data", (chunk: Buffer) => io.stderr(chunk)) child.on("error", (error: NodeJS.ErrnoException) => { if (error.code === "ENOENT" && resolution.kind === "go-run") { @@ -246,9 +278,14 @@ export { RelayfileBinaryNotFoundError, resolveRelayfileBinary, findSourceCheckoutRoot, + formatBinaryNotFoundMessage, genericBinaryName, platformBinaryName, + platformPackageBinaryName, + platformPackageName, + platformPackageNames, GO_TOOLCHAIN_MISSING_MESSAGE, + RELAYFILE_CLI_BIN_ENV, type RelayfileBinaryResolution, type ResolveRelayfileBinaryOptions } from "./resolve-binary.js" diff --git a/packages/sdk/typescript/src/relay-cli/mount-routing.test.ts b/packages/sdk/typescript/src/relay-cli/mount-routing.test.ts new file mode 100644 index 00000000..2dec6831 --- /dev/null +++ b/packages/sdk/typescript/src/relay-cli/mount-routing.test.ts @@ -0,0 +1,176 @@ +import { spawnSync } from "node:child_process" +import path from "node:path" + +import { beforeAll, describe, expect, it } from "vitest" + +import { createRelayCliSurface, relayfileCommands } from "./index.js" +import { buildRelayfileBinary, temporaryDirectory } from "./testing/build-binary.js" + +/** + * `mount` is the awkward shape in relayfile's command tree: it takes two + * optional positionals *and* has four hidden subcommands, so a generic mounter + * could plausibly mistake `relayfile mount my-workspace ./dir` for a + * subcommand lookup, or hide `mount checkpoint-seal` behind the positionals. + * + * Rather than assert what each flag does, these tests prove equivalence: for a + * set of argv shapes, `surface.run(argv)` must produce the same exit code and + * the same bytes as spawning the binary with that argv directly. That covers + * positionals, hidden subcommands, aliases, and flags in one assertion, and it + * cannot pass by accident. + * + * Every shape here fails inside the binary's own argument validation or + * credential lookup, so nothing touches the network. `HOME` points at an empty + * directory so the binary never reads the caller's real relayfile state. + */ + +let binDir: string +let binaryPath: string +let home: string + +beforeAll(() => { + binDir = buildRelayfileBinary().binDir + binaryPath = path.join( + binDir, + process.platform === "win32" ? "relayfile.exe" : "relayfile" + ) + home = temporaryDirectory("mount-routing-home") + // 3 minutes: a cold `go build` of cmd/relayfile-cli. +}, 180_000) + +interface Capture { + code: number + stdout: string + stderr: string +} + +function childEnv(): NodeJS.ProcessEnv { + return { ...process.env, HOME: home, USERPROFILE: home } +} + +async function throughSurface(argv: readonly string[]): Promise { + const captured = { stdout: "", stderr: "" } + const surface = createRelayCliSurface({ + resolve: { binDirs: [binDir] }, + env: childEnv(), + // Cloud sign-in is exercised in cloud-preflight.test.ts; these tests must + // never open a browser or touch the caller's Cloud session. + skipCloudPreflight: true + }) + const code = await surface.run(argv, { + stdout: (chunk) => { + captured.stdout += chunk + }, + stderr: (chunk) => { + captured.stderr += chunk + } + }) + return { ...captured, code } +} + +function directly(argv: readonly string[]): Capture { + const result = spawnSync(binaryPath, [...argv], { + encoding: "utf8", + env: childEnv() + }) + if (result.error) { + throw result.error + } + return { + code: result.status ?? 1, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "" + } +} + +/** argv shapes that exercise every way `mount` can be invoked. */ +const MOUNT_INVOCATIONS: ReadonlyArray<{ label: string; argv: readonly string[] }> = [ + { label: "no positionals", argv: ["mount", "--help"] }, + { + label: "both positionals", + argv: ["mount", "surface-test-missing-workspace", "/tmp/relayfile-surface-test"] + }, + { + label: "positionals plus flags", + argv: [ + "mount", + "surface-test-missing-workspace", + "/tmp/relayfile-surface-test", + "--mode", + "poll", + "--once" + ] + }, + { + label: "workspace positional only", + argv: ["mount", "surface-test-missing-workspace", "--once"] + }, + { label: "hidden subcommand, no flags", argv: ["mount", "checkpoint-seal"] }, + { + label: "hidden subcommand with flags", + argv: ["mount", "checkpoint-seal", "--root", "/tmp/relayfile-surface-test", "--json"] + }, + { + label: "hidden subcommand resume-seal", + argv: ["mount", "resume-seal", "--root", "/tmp/relayfile-surface-test", "--json"] + }, + { label: "hidden subcommand verify-seal", argv: ["mount", "verify-seal", "--help"] }, + { label: "hidden subcommand handback-seal", argv: ["mount", "handback-seal", "--help"] }, + { label: "alias start", argv: ["start", "--help"] }, + { label: "alias on", argv: ["on", "--help"] }, + { + label: "alias with positionals", + argv: ["start", "surface-test-missing-workspace", "/tmp/relayfile-surface-test"] + } +] + +describe("mount routes through the surface exactly as the binary does", () => { + it.each(MOUNT_INVOCATIONS)("$label", async ({ argv }) => { + const mounted = await throughSurface(argv) + const native = directly(argv) + + expect(mounted.code, `exit code for \`${argv.join(" ")}\``).toBe(native.code) + expect(mounted.stdout).toBe(native.stdout) + expect(mounted.stderr).toBe(native.stderr) + // The surface must never have short-circuited: its own unknown-command + // path is the one failure mode that would look like a routing success. + expect(mounted.stderr).not.toContain("unknown command") + }, 60_000) + + it("reaches the mount implementation, not a subcommand lookup", async () => { + // A positional that is not one of the hidden subcommand names must be + // handled as WORKSPACE. The binary echoes it back in its own error, which + // is proof the argument arrived where mount expected it. + const result = await throughSurface([ + "mount", + "surface-test-missing-workspace", + "/tmp/relayfile-surface-test" + ]) + expect(result.code).not.toBe(0) + expect(result.stderr).toContain("surface-test-missing-workspace") + }, 60_000) + + it("returns the binary's exit 2, not its own unknown-command exit 2", async () => { + // `mount checkpoint-seal` with no flags exits 2 from inside the binary, + // colliding with RELAY_CLI_EXIT_UNKNOWN_COMMAND. The two must stay + // distinguishable by what actually ran. + const result = await throughSurface(["mount", "checkpoint-seal"]) + expect(result.code).toBe(2) + expect(result.stderr).toContain("checkpoint_invalid_input") + expect(result.stderr).not.toContain("unknown command") + }, 60_000) + + it("declares every hidden mount subcommand the binary routes", () => { + const mount = relayfileCommands().find((command) => command.name === "mount") + expect(mount).toBeDefined() + const hidden = (mount?.subcommands ?? []).filter((child) => child.hidden) + expect(hidden.map((child) => child.name).sort()).toEqual([ + "checkpoint-seal", + "handback-seal", + "resume-seal", + "verify-seal" + ]) + // Positionals stay optional, so `mount` alone and `mount ` + // both remain expressible in the declared tree. + expect(mount?.args?.every((arg) => !arg.required)).toBe(true) + }) +}) diff --git a/packages/sdk/typescript/src/relay-cli/platform-packages.test.ts b/packages/sdk/typescript/src/relay-cli/platform-packages.test.ts new file mode 100644 index 00000000..4ef32391 --- /dev/null +++ b/packages/sdk/typescript/src/relay-cli/platform-packages.test.ts @@ -0,0 +1,267 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs" +import path from "node:path" + +import { describe, expect, it } from "vitest" + +import { platformPackageBinaryName, platformPackageNames } from "./resolve-binary.js" +import { checkoutRoot } from "./testing/build-binary.js" + +/** + * The `@relayfile/cli--` packages are the only way the + * relayfile binary reaches a consumer that depends on `@relayfile/sdk` without + * also depending on `relayfile` — `agent-relay file` is that consumer, and it + * shipped once with no binary at all because nothing checked. + * + * Every list that has to agree is checked here: the package directories, the + * SDK's optionalDependencies, the resolver's target table, the two build + * scripts that produce the binaries, and the publish workflow that releases + * them. A package that exists but is never published, or is published but + * cannot be built, fails this file rather than a user's install. + */ + +const root = checkoutRoot() +const sdkPackagePath = path.join(root, "packages", "sdk", "typescript", "package.json") + +interface PlatformPackage { + /** Directory name under `packages/`, e.g. `cli-linux-x64`. */ + directory: string + /** npm package name. */ + name: string + /** Node platform id. */ + platform: string + /** Node arch id. */ + arch: string + manifest: Record +} + +function readJson(filePath: string): Record { + return JSON.parse(readFileSync(filePath, "utf8")) as Record +} + +function packageDirectories(prefix: string): readonly string[] { + return readdirSync(path.join(root, "packages")) + .filter((entry) => entry.startsWith(`${prefix}-`)) + .sort() +} + +const platformPackages: readonly PlatformPackage[] = packageDirectories("cli").map( + (directory) => { + const [, platform, arch] = directory.split("-") + return { + directory, + name: `@relayfile/${directory}`, + platform, + arch, + manifest: readJson(path.join(root, "packages", directory, "package.json")) + } + } +) + +const sdkManifest = readJson(sdkPackagePath) +const sdkVersion = sdkManifest.version as string +const optionalDependencies = (sdkManifest.optionalDependencies ?? {}) as Record< + string, + string +> +const workflow = readFileSync( + path.join(root, ".github", "workflows", "publish.yml"), + "utf8" +) + +describe("cli platform packages", () => { + it("has a package directory for every target the resolver looks for", () => { + // Both directions: a resolver target with no package would throw + // "install @relayfile/cli-..." naming something that does not exist, and a + // package the resolver never looks for is dead weight on every install. + expect(platformPackages.map((entry) => entry.name)).toEqual([ + ...platformPackageNames() + ]) + }) + + it("declares the os/cpu that npm filters the install on", () => { + for (const entry of platformPackages) { + expect(entry.manifest.name, entry.directory).toBe(entry.name) + expect(entry.manifest.os, entry.directory).toEqual([entry.platform]) + expect(entry.manifest.cpu, entry.directory).toEqual([entry.arch]) + expect(entry.manifest.files, entry.directory).toEqual(["bin"]) + expect(entry.manifest.version, entry.directory).toBe(sdkVersion) + expect(entry.manifest.publishConfig, entry.directory).toEqual({ + access: "public" + }) + expect( + (entry.manifest.repository as { directory?: string } | undefined)?.directory, + entry.directory + ).toBe(`packages/${entry.directory}`) + } + }) + + it("runs nothing at install time", () => { + // The whole point of the platform-package pattern over the `relayfile` + // package's postinstall download: no scripts, no network, works offline + // and behind a firewall, integrity from the registry. + for (const entry of platformPackages) { + expect(entry.manifest.scripts, entry.directory).toBeUndefined() + expect(entry.manifest.dependencies, entry.directory).toBeUndefined() + expect(entry.manifest.optionalDependencies, entry.directory).toBeUndefined() + } + }) + + it("keeps the same manifest shape as the mount platform packages", () => { + // The instruction was to mirror @relayfile/mount-*, not to invent a second + // pattern. Compare key sets rather than values. + const mountManifest = readJson( + path.join(root, "packages", "mount-linux-x64", "package.json") + ) + const expected = Object.keys(mountManifest).sort() + for (const entry of platformPackages) { + expect(Object.keys(entry.manifest).sort(), entry.directory).toEqual(expected) + } + }) + + it("keeps the bin skeleton tracked and the binary untracked", () => { + const gitignore = readFileSync(path.join(root, ".gitignore"), "utf8") + expect(gitignore).toContain("!packages/cli-*/bin/") + expect(gitignore).toContain("!packages/cli-*/bin/.gitkeep") + expect(gitignore).toContain("packages/cli-*/bin/relayfile-cli") + expect(gitignore).toContain("packages/cli-*/bin/relayfile-cli.exe") + for (const entry of platformPackages) { + expect( + existsSync(path.join(root, "packages", entry.directory, "bin", ".gitkeep")), + entry.directory + ).toBe(true) + } + }) + + it("is an exactly pinned optional dependency of @relayfile/sdk", () => { + // Optional so a platform with no package (or an --omit=optional install) + // still installs the SDK; exact so a consumer can never end up with a + // binary from a different release than the surface that spawns it. + for (const entry of platformPackages) { + expect(optionalDependencies[entry.name], entry.name).toBe(sdkVersion) + } + }) + + it("has no cli platform package the SDK does not install", () => { + const declared = Object.keys(optionalDependencies) + .filter((name) => name.startsWith("@relayfile/cli-")) + .sort() + expect(declared).toEqual(platformPackages.map((entry) => entry.name)) + }) +}) + +describe("cli platform package build", () => { + it("cross-compiles a binary for every platform package", () => { + // "I do not want to publish a package we cannot produce": every package + // must have a matching target in the CLI package's build script. + const source = readFileSync( + path.join(root, "packages", "cli", "scripts", "build-binaries.js"), + "utf8" + ) + const goTargets = new Set( + [...source.matchAll(/goos:\s*"([a-z0-9]+)",\s*goarch:\s*"([a-z0-9]+)"/g)].map( + (match) => `${match[1]}-${match[2]}` + ) + ) + expect(goTargets.size).toBeGreaterThan(0) + + const goOs: Record = { win32: "windows" } + const goArch: Record = { x64: "amd64" } + for (const entry of platformPackages) { + const target = `${goOs[entry.platform] ?? entry.platform}-${ + goArch[entry.arch] ?? entry.arch + }` + expect(goTargets.has(target), `${entry.directory} needs ${target}`).toBe(true) + } + }) + + it("is filled by build-cli-npm-packages.mjs for every platform package", () => { + const source = readFileSync( + path.join(root, "scripts", "build-cli-npm-packages.mjs"), + "utf8" + ) + for (const entry of platformPackages) { + expect( + source.includes(`npmOs: '${entry.platform}', npmArch: '${entry.arch}'`), + entry.directory + ).toBe(true) + } + expect(source).toContain("relayfile-cli.exe") + }) + + it("names the binary the resolver looks for", () => { + const source = readFileSync( + path.join(root, "scripts", "build-cli-npm-packages.mjs"), + "utf8" + ) + expect(source).toContain(`'${platformPackageBinaryName("linux")}'`) + expect(source).toContain(`'${platformPackageBinaryName("win32")}'`) + }) +}) + +describe("cli platform package release", () => { + /** The `git add` argument list in the release-commit step. */ + function releaseCommitBlock(): string { + const start = workflow.indexOf(" git add \\\n") + expect(start).toBeGreaterThan(-1) + const end = workflow.indexOf("\n if !", start) + expect(end).toBeGreaterThan(start) + return workflow.slice(start, end) + } + + it("is published by the publish workflow", () => { + // Listed everywhere a release needs it: the dispatch choice, the input + // allowlist, the version-sync path list, the build artifact, both publish + // matrices, the single-package dispatch, the release commit, and the + // release notes. A package missing from any one of these silently never + // ships, which is the exact failure this whole change is fixing. + for (const entry of platformPackages) { + const required: Array<[string, string]> = [ + ["dispatch choice", ` - ${entry.directory}\n`], + ["input allowlist", `|${entry.directory}`], + ["version sync", `"packages/${entry.directory}/package.json"`], + ["build artifact", `packages/${entry.directory}/bin/.gitkeep`], + ["publish matrix", ` - package: ${entry.directory}\n`], + ["single dispatch", `${entry.directory}) echo "path=packages/${entry.directory}"`], + ["release notes", `- \`${entry.name}@$`] + ] + for (const [label, needle] of required) { + expect(workflow.includes(needle), `${entry.directory}: ${label}`).toBe(true) + } + expect( + releaseCommitBlock().includes(`packages/${entry.directory}/package.json`), + `${entry.directory}: release commit` + ).toBe(true) + } + }) + + it("appears in both the preflight and publish matrices", () => { + for (const entry of platformPackages) { + expect( + workflow.split(` - package: ${entry.directory}\n`).length - 1, + entry.directory + ).toBe(2) + } + }) + + it("copies the cross-compiled binary into the package", () => { + expect(workflow).toContain("Prepare cli platform package") + expect(workflow).toContain('BIN_NAME=relayfile-cli') + expect(workflow).toContain('case "${{ matrix.cli_binary }}" in *.exe) BIN_NAME=relayfile-cli.exe ;; esac') + // Every matrix entry declares cli_binary explicitly: an undefined matrix + // key is not '' in a GitHub expression, so the `!= ''` guard would fire. + const matrixPackages = workflow.match(/^ - package: /gm) ?? [] + const matrixCliBinary = workflow.match(/^ cli_binary: /gm) ?? [] + expect(matrixCliBinary.length).toBe(matrixPackages.length) + }) + + it("expects one attestation per published package", () => { + // The release job counts attestation files; adding packages without + // raising the count fails the release *after* npm already has them. + const publishedPackages = + workflow.match(/^ "packages\/[^"]+\/package\.json"/gm) ?? [] + expect(publishedPackages.length).toBeGreaterThan(platformPackages.length) + expect(workflow).toContain( + `-name '*.json' | wc -l | tr -d ' ')" -eq ${publishedPackages.length}` + ) + }) +}) diff --git a/packages/sdk/typescript/src/relay-cli/resolve-binary.test.ts b/packages/sdk/typescript/src/relay-cli/resolve-binary.test.ts index 39d21b15..c7f600e8 100644 --- a/packages/sdk/typescript/src/relay-cli/resolve-binary.test.ts +++ b/packages/sdk/typescript/src/relay-cli/resolve-binary.test.ts @@ -1,20 +1,28 @@ -import { existsSync, readFileSync } from "node:fs" +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" import path from "node:path" import { describe, expect, it } from "vitest" import { + RELAYFILE_CLI_BIN_ENV, RelayfileBinaryNotFoundError, findSourceCheckoutRoot, + formatBinaryNotFoundMessage, platformBinaryName, - resolveRelayfileBinary + platformPackageBinaryName, + platformPackageName, + platformPackageNames, + resolveRelayfileBinary, + type ResolveRelayfileBinaryOptions } from "./resolve-binary.js" -import { checkoutRoot } from "./testing/build-binary.js" +import { checkoutRoot, temporaryDirectory } from "./testing/build-binary.js" /** * Binary resolution used to live in packages/cli/scripts/run.js. These tests * pin the behavior that moved here, including the fallbacks the bin shim - * depends on. + * depends on, and the `@relayfile/cli--` platform packages + * that are the only path a consumer of @relayfile/sdk has when it does not + * also depend on `relayfile`. */ function fakeFs(present: readonly string[]): (candidate: string) => boolean { @@ -22,6 +30,26 @@ function fakeFs(present: readonly string[]): (candidate: string) => boolean { return (candidate) => set.has(candidate) } +/** + * Resolve with every ambient input sealed off. + * + * The real resolver reads `RELAYFILE_CLI_BIN`, `PATH`, and the installed + * `@relayfile/cli-*` package. A test that leaves any of those live passes or + * fails based on the host it runs on — which is exactly the blind spot that + * let `agent-relay file` ship with no binary. Every knob is closed here and + * opened one at a time. + */ +function resolve( + options: ResolveRelayfileBinaryOptions = {} +): ReturnType { + return resolveRelayfileBinary({ + env: {}, + resolveFrom: [], + pathEntries: [], + ...options + }) +} + describe("platformBinaryName", () => { it("maps node platform/arch onto the packaged Go binary names", () => { expect(platformBinaryName("linux", "x64")).toBe("relayfile-cli-linux-amd64") @@ -36,12 +64,112 @@ describe("platformBinaryName", () => { }) }) +describe("platformPackageName", () => { + it("names the optional dependency for every published target", () => { + expect(platformPackageName("linux", "x64")).toBe("@relayfile/cli-linux-x64") + expect(platformPackageName("darwin", "arm64")).toBe("@relayfile/cli-darwin-arm64") + expect(platformPackageName("win32", "x64")).toBe("@relayfile/cli-win32-x64") + }) + + it("returns null where no package is published", () => { + expect(platformPackageName("aix", "ppc64")).toBeNull() + expect(platformPackageName("linux", "ppc64")).toBeNull() + }) + + it("uses the Go binary's own name inside the package, .exe on Windows", () => { + // Not `relayfile`: the `relayfile` package installs that name, and a + // machine can have both packages. + expect(platformPackageBinaryName("linux")).toBe("relayfile-cli") + expect(platformPackageBinaryName("darwin")).toBe("relayfile-cli") + expect(platformPackageBinaryName("win32")).toBe("relayfile-cli.exe") + }) +}) + describe("resolveRelayfileBinary", () => { + it("prefers the platform package over every other candidate", () => { + // The production path for `agent-relay file`: agent-relay depends on + // @relayfile/sdk, never on `relayfile`, so this is the only binary that + // exists in its tree. + const pkgRoot = temporaryDirectory("cli-platform-pkg") + const pkgDir = path.join(pkgRoot, "node_modules", "@relayfile", "cli-linux-x64") + mkdirSync(path.join(pkgDir, "bin"), { recursive: true }) + writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ name: "@relayfile/cli-linux-x64", version: "0.0.0-test" }) + ) + const packaged = path.join(pkgDir, "bin", "relayfile-cli") + writeFileSync(packaged, "#!/bin/sh\nexit 0\n") + writeFileSync(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "consumer" })) + + const otherBinDir = path.join("/pkg", "bin") + const resolution = resolve({ + resolveFrom: [path.join(pkgRoot, "package.json")], + binDirs: [otherBinDir], + platform: "linux", + arch: "x64", + // The `relayfile` package's own binary is present too and must lose. + fileExists: (candidate) => + candidate === packaged || candidate === path.join(otherBinDir, "relayfile") + }) + + expect(resolution).toEqual({ + kind: "binary", + command: packaged, + args: [], + binaryPath: packaged + }) + }) + + it("looks for the .exe inside the win32 platform package", () => { + const pkgRoot = temporaryDirectory("cli-platform-pkg-win32") + const pkgDir = path.join(pkgRoot, "node_modules", "@relayfile", "cli-win32-x64") + mkdirSync(path.join(pkgDir, "bin"), { recursive: true }) + writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ name: "@relayfile/cli-win32-x64", version: "0.0.0-test" }) + ) + const packaged = path.join(pkgDir, "bin", "relayfile-cli.exe") + writeFileSync(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "consumer" })) + + const resolution = resolve({ + resolveFrom: [path.join(pkgRoot, "package.json")], + binDirs: [], + platform: "win32", + arch: "x64", + fileExists: fakeFs([packaged]) + }) + expect(resolution).toMatchObject({ kind: "binary", command: packaged }) + }) + + it("ignores a platform package that has no binary in it", () => { + // The checked-in skeleton has bin/.gitkeep and nothing else; it must not + // short-circuit the chain in a source checkout. + const pkgRoot = temporaryDirectory("cli-platform-pkg-empty") + const pkgDir = path.join(pkgRoot, "node_modules", "@relayfile", "cli-linux-x64") + mkdirSync(path.join(pkgDir, "bin"), { recursive: true }) + writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ name: "@relayfile/cli-linux-x64", version: "0.0.0-test" }) + ) + writeFileSync(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "consumer" })) + + const binDir = path.join("/pkg", "bin") + const fallback = path.join(binDir, "relayfile") + const resolution = resolve({ + resolveFrom: [path.join(pkgRoot, "package.json")], + binDirs: [binDir], + platform: "linux", + arch: "x64", + fileExists: fakeFs([fallback]) + }) + expect(resolution).toMatchObject({ command: fallback }) + }) + it("prefers a locally built generic binary over the packaged one", () => { const binDir = path.join("/pkg", "bin") const generic = path.join(binDir, "relayfile") const packaged = path.join(binDir, "relayfile-cli-linux-amd64") - const resolution = resolveRelayfileBinary({ + const resolution = resolve({ binDirs: [binDir], platform: "linux", arch: "x64", @@ -58,7 +186,7 @@ describe("resolveRelayfileBinary", () => { it("falls back to the packaged per-platform binary", () => { const binDir = path.join("/pkg", "bin") const packaged = path.join(binDir, "relayfile-cli-darwin-arm64") - const resolution = resolveRelayfileBinary({ + const resolution = resolve({ binDirs: [binDir], platform: "darwin", arch: "arm64", @@ -70,7 +198,7 @@ describe("resolveRelayfileBinary", () => { it("uses the .exe name on Windows", () => { const binDir = path.join("/pkg", "bin") const generic = path.join(binDir, "relayfile.exe") - const resolution = resolveRelayfileBinary({ + const resolution = resolve({ binDirs: [binDir], platform: "win32", arch: "x64", @@ -83,7 +211,7 @@ describe("resolveRelayfileBinary", () => { const first = path.join("/first", "bin") const second = path.join("/second", "bin") const secondBinary = path.join(second, "relayfile") - const resolution = resolveRelayfileBinary({ + const resolution = resolve({ binDirs: [first, second], platform: "linux", arch: "x64", @@ -92,11 +220,41 @@ describe("resolveRelayfileBinary", () => { expect(resolution).toMatchObject({ command: secondBinary }) }) + it("finds make build and make release outputs in a checkout", () => { + const repoRoot = path.join("/work", "relayfile") + const checkout = [ + path.join(repoRoot, "go.mod"), + path.join(repoRoot, "cmd", "relayfile-cli") + ] + const madeBinary = path.join(repoRoot, "bin", "relayfile-cli") + expect( + resolve({ + binDirs: [], + searchFrom: [repoRoot], + platform: "linux", + arch: "x64", + fileExists: fakeFs([...checkout, madeBinary]) + }) + ).toMatchObject({ kind: "binary", command: madeBinary }) + + // `make release` writes Go-named files into dist/. + const released = path.join(repoRoot, "dist", "relayfile-cli-linux-amd64") + expect( + resolve({ + binDirs: [], + searchFrom: [repoRoot], + platform: "linux", + arch: "x64", + fileExists: fakeFs([...checkout, released]) + }) + ).toMatchObject({ kind: "binary", command: released }) + }) + it("runs from Go source in a checkout when no binary is built", () => { // postinstall intentionally skips building the binary in a source // checkout; without this fallback the installed command is unusable there. const repoRoot = path.join("/work", "relayfile") - const resolution = resolveRelayfileBinary({ + const resolution = resolve({ binDirs: [], searchFrom: [path.join(repoRoot, "packages", "cli", "scripts")], platform: "linux", @@ -114,19 +272,90 @@ describe("resolveRelayfileBinary", () => { }) }) - it("throws a reinstall hint when nothing is usable", () => { + it("honors RELAYFILE_CLI_BIN ahead of everything else", () => { + const pinned = path.join("/opt", "relayfile", "relayfile-cli") + const binDir = path.join("/pkg", "bin") + const resolution = resolve({ + env: { [RELAYFILE_CLI_BIN_ENV]: pinned }, + binDirs: [binDir], + platform: "linux", + arch: "x64", + fileExists: fakeFs([pinned, path.join(binDir, "relayfile")]) + }) + expect(resolution).toMatchObject({ kind: "binary", command: pinned }) + }) + + it("ignores RELAYFILE_CLI_BIN when it points at nothing", () => { + const binDir = path.join("/pkg", "bin") + const fallback = path.join(binDir, "relayfile") + const resolution = resolve({ + env: { [RELAYFILE_CLI_BIN_ENV]: path.join("/gone", "relayfile-cli") }, + binDirs: [binDir], + platform: "linux", + arch: "x64", + fileExists: fakeFs([fallback]) + }) + expect(resolution).toMatchObject({ command: fallback }) + }) + + it("scans PATH last, and only for relayfile-cli", () => { + // Never the generic `relayfile`: on PATH that is the npm bin shim, which + // resolves through this module, so spawning it would recurse forever. + const pathDir = path.join("/usr", "local", "bin") + const onPath = path.join(pathDir, "relayfile-cli") + expect( + resolve({ + binDirs: [], + searchFrom: [path.join("/nowhere", "deep")], + pathEntries: [pathDir], + platform: "linux", + arch: "x64", + fileExists: fakeFs([onPath]) + }) + ).toMatchObject({ kind: "binary", command: onPath }) + expect(() => - resolveRelayfileBinary({ + resolve({ binDirs: [], searchFrom: [path.join("/nowhere", "deep")], + pathEntries: [pathDir], platform: "linux", arch: "x64", - fileExists: fakeFs([]) + fileExists: fakeFs([path.join(pathDir, "relayfile")]) }) ).toThrowError(RelayfileBinaryNotFoundError) + }) + it("names the package to install when nothing is usable", () => { + // Not a bare ENOENT: the prebuilt binary is an optional dependency, so the + // message has to say which one and how it goes missing. + let thrown: RelayfileBinaryNotFoundError | undefined try { - resolveRelayfileBinary({ + resolve({ + binDirs: [], + searchFrom: [path.join("/nowhere", "deep")], + platform: "linux", + arch: "x64", + fileExists: fakeFs([]) + }) + } catch (error) { + thrown = error as RelayfileBinaryNotFoundError + } + + expect(thrown).toBeInstanceOf(RelayfileBinaryNotFoundError) + expect(thrown?.platformPackage).toBe("@relayfile/cli-linux-x64") + const message = thrown?.message ?? "" + expect(message).toContain("linux x64") + expect(message).toContain("npm install @relayfile/cli-linux-x64") + expect(message).toContain("--include=optional") + expect(message).toContain("--omit=optional") + expect(message).toContain(RELAYFILE_CLI_BIN_ENV) + }) + + it("says to build from source on a target with no published package", () => { + let thrown: RelayfileBinaryNotFoundError | undefined + try { + resolve({ binDirs: [], searchFrom: [path.join("/nowhere", "deep")], platform: "aix", @@ -134,10 +363,20 @@ describe("resolveRelayfileBinary", () => { fileExists: fakeFs([]) }) } catch (error) { - expect((error as Error).message).toBe( - "relayfile binary not found for aix ppc64. Reinstall the package or run postinstall again." - ) + thrown = error as RelayfileBinaryNotFoundError } + + expect(thrown?.platformPackage).toBeNull() + expect(thrown?.message).toContain("no prebuilt CLI binary for aix ppc64") + expect(thrown?.message).toContain("go build ./cmd/relayfile-cli") + // Nothing to npm-install, so it must not suggest a package that does not exist. + expect(thrown?.message).not.toContain("@relayfile/cli-aix-ppc64") + }) + + it("formats the not-found message without throwing", () => { + expect(formatBinaryNotFoundMessage("darwin", "arm64")).toContain( + "@relayfile/cli-darwin-arm64" + ) }) }) @@ -166,4 +405,15 @@ describe("single implementation", () => { expect(source).not.toContain("relayfile-cli-") expect(source).not.toMatch(/PLATFORM_MAP|ARCH_MAP/) }) + + it("exposes every published platform package name", () => { + expect([...platformPackageNames()]).toEqual([ + "@relayfile/cli-darwin-arm64", + "@relayfile/cli-darwin-x64", + "@relayfile/cli-linux-arm64", + "@relayfile/cli-linux-x64", + "@relayfile/cli-win32-arm64", + "@relayfile/cli-win32-x64" + ]) + }) }) diff --git a/packages/sdk/typescript/src/relay-cli/resolve-binary.ts b/packages/sdk/typescript/src/relay-cli/resolve-binary.ts index ae9baa6b..42d6ba9f 100644 --- a/packages/sdk/typescript/src/relay-cli/resolve-binary.ts +++ b/packages/sdk/typescript/src/relay-cli/resolve-binary.ts @@ -6,6 +6,19 @@ * (`packages/cli/scripts/run.js`) and the `agent-relay file` CLI surface in * this directory — resolve it through this module. Nothing else in the repo * may reimplement the lookup. + * + * The binary reaches a machine two different ways, and the resolver has to + * cope with both: + * + * - As `@relayfile/cli--`, an optional dependency of this + * package. npm installs only the one matching the host's `os`/`cpu`, so + * nothing is downloaded at install time, the install works offline and in + * CI, and integrity comes from the registry. This is the only path that + * exists for a consumer that depends on `@relayfile/sdk` without depending + * on `relayfile` — `agent-relay` is exactly that consumer. + * - Inside the `relayfile` package's own `bin/`, put there by that package's + * `postinstall` (`packages/cli/scripts/install.js`), which downloads the + * per-platform build from GitHub Releases. */ import { createRequire } from "node:module" @@ -25,6 +38,25 @@ const ARCH_MAP: Record = { arm64: "arm64" } +/** + * Node platform/arch pairs that have a published `@relayfile/cli-*` package. + * + * Kept in lockstep with this package's `optionalDependencies`, the target list + * in `packages/cli/scripts/build-binaries.js`, and the package directories + * under `packages/cli-*`. `platform-packages.test.ts` fails when they diverge. + */ +const PLATFORM_PACKAGE_TARGETS: readonly string[] = [ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "win32-arm64", + "win32-x64" +] + +/** Environment variable that pins the binary, bypassing every other step. */ +export const RELAYFILE_CLI_BIN_ENV = "RELAYFILE_CLI_BIN" + /** How the resolver decided to launch relayfile. */ export type RelayfileBinaryResolution = | { @@ -57,25 +89,113 @@ export interface ResolveRelayfileBinaryOptions { binDirs?: readonly string[] /** Extra directories to start the source-checkout search from. */ searchFrom?: readonly string[] + /** + * Anchors for the `require.resolve` that finds + * `@relayfile/cli--`. Defaults to this module, the entry + * script, and the cwd — the three places a consumer's copy of the package + * can be reached from. + */ + resolveFrom?: readonly string[] + /** `PATH` entries for the last-resort lookup. Defaults to `env.PATH`. */ + pathEntries?: readonly string[] + /** Environment read for `RELAYFILE_CLI_BIN` and `PATH`. */ + env?: NodeJS.ProcessEnv platform?: string arch?: string /** Injected for tests. */ fileExists?: (candidate: string) => boolean } +/** + * Name of the npm package carrying the prebuilt binary for a target. + * + * @param platform - Node platform id; defaults to this host's. + * @param arch - Node arch id; defaults to this host's. + * @returns The package name, or null when no package is published for the + * target (nothing to suggest installing, so callers say "build from source"). + */ +export function platformPackageName( + platform: string = os.platform(), + arch: string = os.arch() +): string | null { + const target = `${platform}-${arch}` + return PLATFORM_PACKAGE_TARGETS.includes(target) + ? `@relayfile/cli-${target}` + : null +} + +/** Every `@relayfile/cli-*` package name, for tests and release tooling. */ +export function platformPackageNames(): readonly string[] { + return PLATFORM_PACKAGE_TARGETS.map((target) => `@relayfile/cli-${target}`) +} + +/** + * Name of the binary inside a `@relayfile/cli-*` package. + * + * It keeps the Go binary's own name (matching `relayfile-mount` inside the + * `@relayfile/mount-*` packages) rather than the `relayfile` name the CLI + * package installs, so the two never collide on a machine that has both. + * + * @param platform - Node platform id; defaults to this host's. + * @returns The file name, with `.exe` on Windows. + */ +export function platformPackageBinaryName( + platform: string = os.platform() +): string { + return platform === "win32" ? "relayfile-cli.exe" : "relayfile-cli" +} + /** Thrown when neither a binary nor a usable source checkout was found. */ export class RelayfileBinaryNotFoundError extends Error { readonly platform: string readonly arch: string + /** The `@relayfile/cli-*` package for this target, when one is published. */ + readonly platformPackage: string | null constructor(platform: string, arch: string) { - super( - `relayfile binary not found for ${platform} ${arch}. Reinstall the package or run postinstall again.` - ) + super(formatBinaryNotFoundMessage(platform, arch)) this.name = "RelayfileBinaryNotFoundError" this.platform = platform this.arch = arch + this.platformPackage = platformPackageName(platform, arch) + } +} + +/** + * Explain what to install, for this exact platform. + * + * A bare ENOENT sends people looking for a bug in their PATH. The prebuilt + * binary is an optional dependency, so the two things that actually cause this + * — `--omit=optional` and an unsupported target — both need naming. + * + * @param platform - Node platform id. + * @param arch - Node arch id. + * @returns The message carried by `RelayfileBinaryNotFoundError`. + */ +export function formatBinaryNotFoundMessage( + platform: string, + arch: string +): string { + const packageName = platformPackageName(platform, arch) + if (!packageName) { + return ( + `relayfile has no prebuilt CLI binary for ${platform} ${arch}. ` + + `Prebuilt binaries exist for ${PLATFORM_PACKAGE_TARGETS.join(", ")}. ` + + `Build one from source with \`go build ./cmd/relayfile-cli\` and point ` + + `${RELAYFILE_CLI_BIN_ENV} at it.` + ) } + return ( + `relayfile could not find a relayfile-cli binary for ${platform} ${arch}. ` + + `The prebuilt binary ships as ${packageName}, an optional dependency of ` + + `@relayfile/sdk, so it is missing when the install ran with ` + + `--omit=optional or could not fetch optional packages. Fix it with one of:\n` + + ` npm install ${packageName}\n` + + ` npm install @relayfile/sdk --include=optional\n` + + ` npm install -g relayfile (the standalone CLI)\n` + + `Or set ${RELAYFILE_CLI_BIN_ENV} to a binary you built or downloaded from ` + + `https://github.com/AgentWorkforce/relayfile/releases.` + ) } /** @@ -178,13 +298,137 @@ export function findSourceCheckoutRoot( ) } +/** + * Go's GOARCH ("amd64") differs from Node's `process.arch` ("x64"). The + * platform package name follows Node; `make build-all` / `make release` output + * file names follow Go. + */ +function goArch(arch: string): string { + return ARCH_MAP[arch] ?? arch +} + +/** + * Locate the binary inside `@relayfile/cli--`. + * + * Resolved by `require.resolve` from several anchors rather than a fixed + * relative path: the package can sit anywhere npm decides to hoist it, and the + * SDK itself may be nested under a consumer's `node_modules` or bundled. + * + * @returns The binary path, or null when the package is not installed here + * (expected under `--omit=optional`, or on an unsupported target). + */ +function platformPackageBinary( + platform: string, + arch: string, + exists: (candidate: string) => boolean, + resolveFrom: readonly string[] +): string | null { + const packageName = platformPackageName(platform, arch) + if (!packageName) { + return null + } + const binaryName = platformPackageBinaryName(platform) + for (const anchor of resolveFrom) { + let manifest: string + try { + manifest = createRequire(anchor).resolve(`${packageName}/package.json`) + } catch { + continue + } + const candidate = path.join(path.dirname(manifest), "bin", binaryName) + if (exists(candidate)) { + return candidate + } + } + return null +} + +/** + * Default anchors for resolving the platform package: this module, the entry + * script (a consumer's CLI, where the SDK lives under their `node_modules`), + * and the cwd. + */ +function defaultResolveFrom(): readonly string[] { + const anchors: string[] = [path.join(moduleDirectory(), "resolve-binary.js")] + if (process.argv[1]) { + anchors.push(process.argv[1]) + } + anchors.push(path.join(process.cwd(), "package.json")) + return [...new Set(anchors)] +} + +/** + * `make build` and `make release` outputs inside a source checkout. + * + * @returns Candidate binary paths, highest priority first. + */ +function sourceCheckoutBinaries( + platform: string, + arch: string, + exists: (candidate: string) => boolean, + searchRoots: readonly string[] +): readonly string[] { + const candidates: string[] = [] + const seenRoots = new Set() + for (const start of searchRoots) { + const root = findSourceCheckoutRoot(start, exists) + if (!root || seenRoots.has(root)) { + continue + } + seenRoots.add(root) + candidates.push(path.join(root, "bin", "relayfile-cli")) + candidates.push( + path.join(root, "dist", `relayfile-cli-${PLATFORM_MAP[platform] ?? platform}-${goArch(arch)}`) + ) + } + return candidates +} + +/** + * Last-resort `PATH` scan, for a Go binary placed outside npm. + * + * Deliberately matches `relayfile-cli` only, never the generic `relayfile`: + * on PATH that name is usually the npm bin shim + * (`packages/cli/scripts/run.js`), which resolves its binary through this + * module. Spawning it here would make the resolver call itself forever. + * + * @returns The first match, or null. + */ +function findOnPath( + platform: string, + exists: (candidate: string) => boolean, + pathEntries: readonly string[] +): string | null { + const name = platformPackageBinaryName(platform) + for (const entry of pathEntries) { + const candidate = path.join(entry, name) + if (exists(candidate)) { + return candidate + } + } + return null +} + +function binary(binaryPath: string): RelayfileBinaryResolution { + return { kind: "binary", command: binaryPath, args: [], binaryPath } +} + /** * Resolve how to launch the relayfile CLI on this machine. * - * Search order, preserving the behavior of the `relayfile` bin shim: - * 1. a generic `bin/relayfile` (a locally built binary), then the - * per-platform `bin/relayfile-cli--` the package ships; - * 2. `go run ./cmd/relayfile-cli` from an enclosing source checkout. + * Search order, and why it is this order: + * 1. `RELAYFILE_CLI_BIN` — the escape hatch, so a developer or operator can + * always pin an exact binary. + * 2. `@relayfile/cli--` — the prebuilt binary npm installed + * for this host. First because it is the only path that exists for a + * consumer of `@relayfile/sdk` that does not also depend on `relayfile` + * (`agent-relay file` is that consumer), and because it needs no network. + * 3. The `binDirs` chain: a generic `bin/relayfile` (the `relayfile` + * package's postinstall download, or a local build), then the + * per-platform `bin/relayfile-cli--` that package ships. + * 4. `make build` / `make release` outputs in an enclosing source checkout. + * 5. `go run ./cmd/relayfile-cli` from that checkout. + * 6. `PATH`, for a binary installed outside npm. * * @param options - Search overrides; all are optional. * @returns The command, argv prefix, and cwd to spawn. @@ -196,6 +440,25 @@ export function resolveRelayfileBinary( const exists = options.fileExists ?? existsSync const platform = options.platform ?? os.platform() const arch = options.arch ?? os.arch() + const env = options.env ?? process.env + + const override = env[RELAYFILE_CLI_BIN_ENV] + if (override) { + const resolved = path.resolve(override) + if (exists(resolved)) { + return binary(resolved) + } + } + + const fromPlatformPackage = platformPackageBinary( + platform, + arch, + exists, + options.resolveFrom ?? defaultResolveFrom() + ) + if (fromPlatformPackage) { + return binary(fromPlatformPackage) + } const binDirs = options.binDirs ?? @@ -212,12 +475,19 @@ export function resolveRelayfileBinary( for (const candidate of candidates) { if (exists(candidate)) { - return { kind: "binary", command: candidate, args: [], binaryPath: candidate } + return binary(candidate) } } } const searchRoots = [...(options.searchFrom ?? []), moduleDirectory(), process.cwd()] + + for (const candidate of sourceCheckoutBinaries(platform, arch, exists, searchRoots)) { + if (exists(candidate)) { + return binary(candidate) + } + } + for (const root of searchRoots) { const checkout = findSourceCheckoutRoot(root, exists) if (checkout) { @@ -230,6 +500,13 @@ export function resolveRelayfileBinary( } } + const pathEntries = + options.pathEntries ?? (env.PATH ?? "").split(path.delimiter).filter(Boolean) + const onPath = findOnPath(platform, exists, pathEntries) + if (onPath) { + return binary(onPath) + } + throw new RelayfileBinaryNotFoundError(platform, arch) } diff --git a/packages/sdk/typescript/src/relay-cli/testing/build-binary.ts b/packages/sdk/typescript/src/relay-cli/testing/build-binary.ts index aea38007..4a17afa9 100644 --- a/packages/sdk/typescript/src/relay-cli/testing/build-binary.ts +++ b/packages/sdk/typescript/src/relay-cli/testing/build-binary.ts @@ -18,6 +18,17 @@ import { findSourceCheckoutRoot } from "../resolve-binary.js" let cached: { binDir: string; checkoutRoot: string } | undefined +/** + * Create a throwaway directory for a test that needs a real filesystem — + * resolution through `require.resolve` cannot be faked. + * + * @param prefix - Label included in the directory name. + * @returns The directory path. + */ +export function temporaryDirectory(prefix: string): string { + return mkdtempSync(path.join(os.tmpdir(), `relayfile-${prefix}-`)) +} + /** * Locate the relayfile checkout these tests run inside. * @@ -72,3 +83,46 @@ export function buildRelayfileBinary(): { binDir: string; checkoutRoot: string } cached = { binDir, checkoutRoot: root } return cached } + +let sdkDist: string | undefined + +/** + * Ensure this package's `dist/` exists, for tests that need the SDK as it is + * published rather than as vitest transforms it — resolution through the + * `exports` map only works against the built files. + * + * Builds only when `dist/` is missing, so a normal `npm run build && npm test` + * pays nothing. + * + * @returns The `dist` directory. + * @throws When the build fails. + */ +export function buildSdkDist(): string { + if (sdkDist) { + return sdkDist + } + + // src/relay-cli/testing -> packages/sdk/typescript + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..") + const dist = path.join(packageRoot, "dist") + + if (!existsSync(path.join(dist, "relay-cli", "index.js"))) { + const result = spawnSync("npm", ["run", "build"], { + cwd: packageRoot, + encoding: "utf8" + }) + if (result.status !== 0) { + throw new Error( + `npm run build (packages/sdk/typescript) exited ${result.status}\n${result.stdout}\n${result.stderr}` + ) + } + } + if (!existsSync(path.join(dist, "relay-cli", "command-spec.json"))) { + throw new Error( + "dist/relay-cli/command-spec.json is missing; run `npm run build --workspace=packages/sdk/typescript`" + ) + } + + sdkDist = dist + return dist +} diff --git a/scripts/build-cli-npm-packages.mjs b/scripts/build-cli-npm-packages.mjs new file mode 100644 index 00000000..ae7b9869 --- /dev/null +++ b/scripts/build-cli-npm-packages.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +// Fills the @relayfile/cli-- platform packages with the prebuilt +// relayfile-cli binary so they can be published to npm. Mirrors +// build-mount-npm-packages.mjs and the @relayfile/mount-* layout, which +// @relayfile/sdk resolves at runtime via require.resolve. +// +// These packages are what makes `agent-relay file ` work on a clean +// install: agent-relay depends on @relayfile/sdk but not on `relayfile`, so +// the `relayfile` package's postinstall download never runs for it. Installing +// the binary as a per-platform optional dependency needs no install-time +// network, works offline and in CI, and gets its integrity from the registry. +// +// Usage: +// npm run build --workspace=packages/cli # produce packages/cli/bin/* +// node scripts/build-cli-npm-packages.mjs +// +// For each target it: +// 1. copies the matching prebuilt binary -> packages/cli--/bin/relayfile-cli +// (relayfile-cli.exe on Windows) +// 2. rewrites that package's version to match @relayfile/sdk +// +// Two source layouts are accepted, because two different commands produce the +// binaries: `npm run build --workspace=packages/cli` writes +// packages/cli/bin/relayfile-cli--, and `make release` writes +// dist/relayfile-cli--. The publish workflow builds the CLI +// package, so the first is the one that matters in CI. +// +// Note the arch naming: Go emits `amd64` and `windows`, npm os/cpu uses Node's +// `x64` and `win32`. Source file names follow Go; the package dirs follow Node. +import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' +import { constants } from 'node:fs' +import { access } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(scriptDir, '..') +const distDir = join(repoRoot, 'dist') +const cliBinDir = join(repoRoot, 'packages', 'cli', 'bin') +const packagesDir = join(repoRoot, 'packages') + +// (npmOs/npmArch used in the package dir + os/cpu fields) +// -> (goOs/goArch used in the prebuilt file name) +const TARGETS = [ + { npmOs: 'darwin', npmArch: 'arm64', goOs: 'darwin', goArch: 'arm64' }, + { npmOs: 'darwin', npmArch: 'x64', goOs: 'darwin', goArch: 'amd64' }, + { npmOs: 'linux', npmArch: 'arm64', goOs: 'linux', goArch: 'arm64' }, + { npmOs: 'linux', npmArch: 'x64', goOs: 'linux', goArch: 'amd64' }, + { npmOs: 'win32', npmArch: 'arm64', goOs: 'windows', goArch: 'arm64' }, + { npmOs: 'win32', npmArch: 'x64', goOs: 'windows', goArch: 'amd64' } +] + +async function exists(path) { + try { + await access(path, constants.R_OK) + return true + } catch { + return false + } +} + +async function sdkVersion() { + const sdkPkgPath = join(repoRoot, 'packages', 'sdk', 'typescript', 'package.json') + const raw = await readFile(sdkPkgPath, 'utf8') + return JSON.parse(raw).version +} + +/** + * Prebuilt binary for a target, from whichever build produced it. + * + * @param target - One TARGETS entry. + * @returns The first existing source path, or null when none was built. + */ +async function findSourceBinary(target) { + const extension = target.goOs === 'windows' ? '.exe' : '' + const fileName = `relayfile-cli-${target.goOs}-${target.goArch}${extension}` + for (const candidate of [join(cliBinDir, fileName), join(distDir, fileName)]) { + if (await exists(candidate)) { + return candidate + } + } + return null +} + +async function main() { + const version = await sdkVersion() + const missing = [] + + for (const target of TARGETS) { + const source = await findSourceBinary(target) + const pkgDir = join(packagesDir, `cli-${target.npmOs}-${target.npmArch}`) + const pkgJsonPath = join(pkgDir, 'package.json') + const binaryName = target.npmOs === 'win32' ? 'relayfile-cli.exe' : 'relayfile-cli' + const binTarget = join(pkgDir, 'bin', binaryName) + + if (!source) { + missing.push(`relayfile-cli for ${target.npmOs}-${target.npmArch}`) + continue + } + + await mkdir(dirname(binTarget), { recursive: true }) + await copyFile(source, binTarget) + await chmod(binTarget, 0o755) + + const pkg = JSON.parse(await readFile(pkgJsonPath, 'utf8')) + if (pkg.version !== version) { + pkg.version = version + await writeFile(pkgJsonPath, JSON.stringify(pkg, null, 2) + '\n') + } + + console.log(`[cli-pkg] ${pkg.name}@${version} <- ${source}`) + } + + if (missing.length > 0) { + console.error( + '[cli-pkg] missing prebuilt binaries (run `npm run build --workspace=packages/cli` ' + + `first):\n ${missing.join('\n ')}` + ) + process.exit(1) + } + + console.log(`[cli-pkg] ready to publish ${TARGETS.length} packages at v${version}`) +} + +main().catch((error) => { + console.error('[cli-pkg]', error instanceof Error ? error.message : error) + process.exit(1) +}) From b1fc09aacb1a2a74d12822c16233345597522c11 Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Thu, 17 Sep 2026 15:42:23 -0700 Subject: [PATCH 04/14] chore(trail): record cli platform-package trajectory Co-Authored-By: Claude Opus 5 (1M context) Session-Id: 4e63354c-d2b2-48a1-82e8-21a328d10f6b --- .../completed/2026-09/traj_r0q3qwvib91g.json | 101 ++++++++++++++++++ .../completed/2026-09/traj_r0q3qwvib91g.md | 51 +++++++++ .trajectories/index.json | 9 +- 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 .trajectories/completed/2026-09/traj_r0q3qwvib91g.json create mode 100644 .trajectories/completed/2026-09/traj_r0q3qwvib91g.md diff --git a/.trajectories/completed/2026-09/traj_r0q3qwvib91g.json b/.trajectories/completed/2026-09/traj_r0q3qwvib91g.json new file mode 100644 index 00000000..c600580e --- /dev/null +++ b/.trajectories/completed/2026-09/traj_r0q3qwvib91g.json @@ -0,0 +1,101 @@ +{ + "id": "traj_r0q3qwvib91g", + "version": 1, + "task": { + "title": "Ship relayfile-cli as per-platform npm packages so agent-relay file resolves a binary on a clean install" + }, + "status": "completed", + "startedAt": "2026-09-17T22:41:57.895Z", + "completedAt": "2026-09-17T22:42:19.937Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-09-17T22:42:03.970Z" + } + ], + "chapters": [ + { + "id": "chap_3s2j7v86s8ih", + "title": "Work", + "agentName": "default", + "startedAt": "2026-09-17T22:42:03.970Z", + "endedAt": "2026-09-17T22:42:19.937Z", + "events": [ + { + "ts": 1789684923971, + "type": "decision", + "content": "Ship the CLI binary as @relayfile/cli-- optionalDependencies rather than extending the relayfile package's postinstall download: Ship the CLI binary as @relayfile/cli-- optionalDependencies rather than extending the relayfile package's postinstall download", + "raw": { + "question": "Ship the CLI binary as @relayfile/cli-- optionalDependencies rather than extending the relayfile package's postinstall download", + "chosen": "Ship the CLI binary as @relayfile/cli-- optionalDependencies rather than extending the relayfile package's postinstall download", + "alternatives": [], + "reasoning": "agent-relay depends on @relayfile/sdk, never on relayfile, so a postinstall in the relayfile package can never run for it. The per-platform optional-dependency pattern was already proven in this repo by @relayfile/mount-*, needs no install-time network, works offline and in CI, and gets integrity from the registry" + }, + "significance": "high" + }, + { + "ts": 1789684924109, + "type": "decision", + "content": "Include win32-x64 and win32-arm64 platform packages, unlike the mount packages: Include win32-x64 and win32-arm64 platform packages, unlike the mount packages", + "raw": { + "question": "Include win32-x64 and win32-arm64 platform packages, unlike the mount packages", + "chosen": "Include win32-x64 and win32-arm64 platform packages, unlike the mount packages", + "alternatives": [], + "reasoning": "packages/cli/scripts/build-binaries.js already cross-compiles both Windows targets and publish.yml already asserts 6 relayfile-cli binaries and attaches them to every release, so the artifacts provably exist. Without the packages a win32 agent-relay file user has no resolution path at all, since only the relayfile package's postinstall fetches the .exe" + }, + "significance": "high" + }, + { + "ts": 1789684933564, + "type": "decision", + "content": "Test the production path by assembling a fake npm install under the OS temp dir and running a probe with plain node and an empty PATH: Test the production path by assembling a fake npm install under the OS temp dir and running a probe with plain node and an empty PATH", + "raw": { + "question": "Test the production path by assembling a fake npm install under the OS temp dir and running a probe with plain node and an empty PATH", + "chosen": "Test the production path by assembling a fake npm install under the OS temp dir and running a probe with plain node and an empty PATH", + "alternatives": [], + "reasoning": "The bug shipped because every existing test ran inside this checkout, where a built binary and a Go toolchain are both present, so no test could distinguish 'resolves' from 'resolves because the host happens to have one'. Building the tree outside any go.mod, with no relayfile package and an empty PATH, removes every ambient fallback, and loading the SDK by package name exercises the real exports map and the real require.resolve" + }, + "significance": "high" + }, + { + "ts": 1789684933716, + "type": "decision", + "content": "Match relayfile-cli only in the PATH fallback, never the generic relayfile name: Match relayfile-cli only in the PATH fallback, never the generic relayfile name", + "raw": { + "question": "Match relayfile-cli only in the PATH fallback, never the generic relayfile name", + "chosen": "Match relayfile-cli only in the PATH fallback, never the generic relayfile name", + "alternatives": [], + "reasoning": "On PATH, relayfile is normally the npm bin shim packages/cli/scripts/run.js, which resolves its binary through this same module. Spawning it from the resolver would make the resolver invoke itself forever. make install does place the Go binary at relayfile, so that case is deliberately left to the checkout and binDirs steps instead" + }, + "significance": "high" + }, + { + "ts": 1789684933842, + "type": "decision", + "content": "Implement byte passthrough for export --format tar --output - instead of the fail-fast the brief asked for: Implement byte passthrough for export --format tar --output - instead of the fail-fast the brief asked for", + "raw": { + "question": "Implement byte passthrough for export --format tar --output - instead of the fail-fast the brief asked for", + "chosen": "Implement byte passthrough for export --format tar --output - instead of the fail-fast the brief asked for", + "alternatives": [], + "reasoning": "The brief said to fail fast because widening RelayCliIo was tracked separately, but the linked @agent-relay/cli-surface already declares stdout(chunk: string | Uint8Array) and names this exact command as the motivating case. Shipping a deliberate fail-fast against a capability the live contract provides would be a regression; asked the lead in #cli-surfaces and flagged it as a one-commit revert" + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Added six @relayfile/cli-- packages (4 mount-matching targets plus win32 x64/arm64) as exactly-pinned optionalDependencies of @relayfile/sdk, mirroring the @relayfile/mount-* pattern, with a filler script and full publish.yml wiring. resolveRelayfileBinary now prefers them, falls back through the existing chain to make outputs, go run, and a relayfile-cli-only PATH scan, and reports an actionable install message as exit 127 through io. Output is now byte-exact. 94 relay-cli tests pass including a clean-install E2E that cannot pass on the host's built binary; verified from a packed tarball install with an empty PATH", + "approach": "Standard approach", + "confidence": 0.85 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relayfile", + "tags": [], + "_trace": { + "startRef": "af70448f1ee4064cb6c1b37893c71bcec94d0313", + "endRef": "af70448f1ee4064cb6c1b37893c71bcec94d0313" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-09/traj_r0q3qwvib91g.md b/.trajectories/completed/2026-09/traj_r0q3qwvib91g.md new file mode 100644 index 00000000..785ae9da --- /dev/null +++ b/.trajectories/completed/2026-09/traj_r0q3qwvib91g.md @@ -0,0 +1,51 @@ +# Trajectory: Ship relayfile-cli as per-platform npm packages so agent-relay file resolves a binary on a clean install + +> **Status:** ✅ Completed +> **Confidence:** 85% +> **Started:** September 17, 2026 at 03:41 PM +> **Completed:** September 17, 2026 at 03:42 PM + +--- + +## Summary + +Added six @relayfile/cli-- packages (4 mount-matching targets plus win32 x64/arm64) as exactly-pinned optionalDependencies of @relayfile/sdk, mirroring the @relayfile/mount-* pattern, with a filler script and full publish.yml wiring. resolveRelayfileBinary now prefers them, falls back through the existing chain to make outputs, go run, and a relayfile-cli-only PATH scan, and reports an actionable install message as exit 127 through io. Output is now byte-exact. 94 relay-cli tests pass including a clean-install E2E that cannot pass on the host's built binary; verified from a packed tarball install with an empty PATH + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Ship the CLI binary as @relayfile/cli-- optionalDependencies rather than extending the relayfile package's postinstall download +- **Chose:** Ship the CLI binary as @relayfile/cli-- optionalDependencies rather than extending the relayfile package's postinstall download +- **Reasoning:** agent-relay depends on @relayfile/sdk, never on relayfile, so a postinstall in the relayfile package can never run for it. The per-platform optional-dependency pattern was already proven in this repo by @relayfile/mount-*, needs no install-time network, works offline and in CI, and gets integrity from the registry + +### Include win32-x64 and win32-arm64 platform packages, unlike the mount packages +- **Chose:** Include win32-x64 and win32-arm64 platform packages, unlike the mount packages +- **Reasoning:** packages/cli/scripts/build-binaries.js already cross-compiles both Windows targets and publish.yml already asserts 6 relayfile-cli binaries and attaches them to every release, so the artifacts provably exist. Without the packages a win32 agent-relay file user has no resolution path at all, since only the relayfile package's postinstall fetches the .exe + +### Test the production path by assembling a fake npm install under the OS temp dir and running a probe with plain node and an empty PATH +- **Chose:** Test the production path by assembling a fake npm install under the OS temp dir and running a probe with plain node and an empty PATH +- **Reasoning:** The bug shipped because every existing test ran inside this checkout, where a built binary and a Go toolchain are both present, so no test could distinguish 'resolves' from 'resolves because the host happens to have one'. Building the tree outside any go.mod, with no relayfile package and an empty PATH, removes every ambient fallback, and loading the SDK by package name exercises the real exports map and the real require.resolve + +### Match relayfile-cli only in the PATH fallback, never the generic relayfile name +- **Chose:** Match relayfile-cli only in the PATH fallback, never the generic relayfile name +- **Reasoning:** On PATH, relayfile is normally the npm bin shim packages/cli/scripts/run.js, which resolves its binary through this same module. Spawning it from the resolver would make the resolver invoke itself forever. make install does place the Go binary at relayfile, so that case is deliberately left to the checkout and binDirs steps instead + +### Implement byte passthrough for export --format tar --output - instead of the fail-fast the brief asked for +- **Chose:** Implement byte passthrough for export --format tar --output - instead of the fail-fast the brief asked for +- **Reasoning:** The brief said to fail fast because widening RelayCliIo was tracked separately, but the linked @agent-relay/cli-surface already declares stdout(chunk: string | Uint8Array) and names this exact command as the motivating case. Shipping a deliberate fail-fast against a capability the live contract provides would be a regression; asked the lead in #cli-surfaces and flagged it as a one-commit revert + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Ship the CLI binary as @relayfile/cli-- optionalDependencies rather than extending the relayfile package's postinstall download: Ship the CLI binary as @relayfile/cli-- optionalDependencies rather than extending the relayfile package's postinstall download +- Include win32-x64 and win32-arm64 platform packages, unlike the mount packages: Include win32-x64 and win32-arm64 platform packages, unlike the mount packages +- Test the production path by assembling a fake npm install under the OS temp dir and running a probe with plain node and an empty PATH: Test the production path by assembling a fake npm install under the OS temp dir and running a probe with plain node and an empty PATH +- Match relayfile-cli only in the PATH fallback, never the generic relayfile name: Match relayfile-cli only in the PATH fallback, never the generic relayfile name +- Implement byte passthrough for export --format tar --output - instead of the fail-fast the brief asked for: Implement byte passthrough for export --format tar --output - instead of the fail-fast the brief asked for diff --git a/.trajectories/index.json b/.trajectories/index.json index 529f37d3..dc0042ca 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-09-17T21:27:43.856Z", + "lastUpdated": "2026-09-17T22:42:19.981Z", "trajectories": { "traj_4pvrlmqfnzng": { "title": "Review PR #278 in AgentWorkforce/relayfile", @@ -379,6 +379,13 @@ "startedAt": "2026-09-17T21:00:11.799Z", "completedAt": "2026-09-17T21:27:43.737Z", "path": "/home/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/completed/2026-09/traj_jcnhoywu08ve.json" + }, + "traj_r0q3qwvib91g": { + "title": "Ship relayfile-cli as per-platform npm packages so agent-relay file resolves a binary on a clean install", + "status": "completed", + "startedAt": "2026-09-17T22:41:57.895Z", + "completedAt": "2026-09-17T22:42:19.937Z", + "path": "/home/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/completed/2026-09/traj_r0q3qwvib91g.json" } } } \ No newline at end of file From 048ff2b994f6c7053d6d7048d78d8237a78524bc Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Thu, 17 Sep 2026 16:01:46 -0700 Subject: [PATCH 05/14] chore(sdk): depend on the published @agent-relay/cli-surface The devDependency pointed at a `file:` path in a sibling checkout, so it resolved only on the machine that wrote it. Every CI runner failed the surface and command-spec tests with ERR_MODULE_NOT_FOUND. The contract package is now on npm at 12.2.2; this pins it there. It stays a devDependency: the published package gains no runtime dependency on relay, and the surface is still satisfied structurally. Co-Authored-By: Claude Opus 5 (1M context) Session-Id: d458bd97-53d8-4f02-be9c-48b67b93c916 --- package-lock.json | 10 ++++++++-- packages/sdk/typescript/package.json | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 81bf149c..30c7a804 100644 --- a/package-lock.json +++ b/package-lock.json @@ -902,7 +902,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -8505,7 +8505,7 @@ "tar": "^7.5.10" }, "devDependencies": { - "@agent-relay/cli-surface": "file:../../../../relay/packages/cli-surface", + "@agent-relay/cli-surface": "^12.2.2", "typescript": "^5.7.3", "vitest": "^3.0.0" }, @@ -8513,6 +8513,12 @@ "node": ">=18" }, "optionalDependencies": { + "@relayfile/cli-darwin-arm64": "0.10.56", + "@relayfile/cli-darwin-x64": "0.10.56", + "@relayfile/cli-linux-arm64": "0.10.56", + "@relayfile/cli-linux-x64": "0.10.56", + "@relayfile/cli-win32-arm64": "0.10.56", + "@relayfile/cli-win32-x64": "0.10.56", "@relayfile/mount-darwin-arm64": "0.10.56", "@relayfile/mount-darwin-x64": "0.10.56", "@relayfile/mount-linux-arm64": "0.10.56", diff --git a/packages/sdk/typescript/package.json b/packages/sdk/typescript/package.json index 4de079f9..eaf8d8f0 100644 --- a/packages/sdk/typescript/package.json +++ b/packages/sdk/typescript/package.json @@ -1,7 +1,7 @@ { "name": "@relayfile/sdk", "version": "0.10.56", - "description": "TypeScript SDK for relayfile — real-time filesystem for humans and agents", + "description": "TypeScript SDK for relayfile \u2014 real-time filesystem for humans and agents", "main": "dist/index.js", "types": "dist/index.d.ts", "type": "module", @@ -83,7 +83,7 @@ "@relayfile/mount-linux-x64": "0.10.56" }, "devDependencies": { - "@agent-relay/cli-surface": "file:../../../../relay/packages/cli-surface", + "@agent-relay/cli-surface": "^12.2.2", "typescript": "^5.7.3", "vitest": "^3.0.0" }, From 50cf4d5c278745e8efe8163a73b04e58175184cb Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Thu, 17 Sep 2026 16:32:44 -0700 Subject: [PATCH 06/14] fix(cli-surface): route `version`, find Windows checkout builds, unblock postinstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three behavioural findings from the PR review on the relay CLI surface. `agent-relay file version` exited 2 without spawning: the binary routes a lone `version` through wantsVersion(), the same path as --version, but routableTopLevelNames() listed only `help` and `__command-spec`. It stays out of the declared command tree — that tree is a generated snapshot of the Go command table, which handles `version` outside it and does not advertise it in `relayfile --help` either. Checkout resolution looked for `bin/relayfile-cli` and `dist/relayfile-cli--` with no `.exe`, so on Windows a successful `make build` was invisible and resolution fell through to `go run`. packages/cli postinstall imported @relayfile/sdk/relay-cli before it could detect a source checkout and skip the download. A fresh clone has no SDK dist/, so `npm install` died in postinstall and never reached the skip. The two checkout markers are now tested locally, ahead of any SDK load; install.test.js pins that predicate against the SDK's findSourceCheckoutRoot. Tests: 5 new in packages/cli/scripts/install.test.js (3 fail without the install.js fix), plus `version` routing and Windows checkout cases in the SDK suite (4 fail without the resolver and surface fixes). Co-Authored-By: Claude Opus 5 (1M context) Session-Id: 7377ec91-f877-4cd3-863f-29d31e4de625 --- packages/cli/scripts/install.js | 63 ++++++-- packages/cli/scripts/install.test.js | 140 ++++++++++++++++++ .../sdk/typescript/src/relay-cli/index.ts | 14 +- .../src/relay-cli/resolve-binary.test.ts | 39 +++++ .../src/relay-cli/resolve-binary.ts | 11 +- .../typescript/src/relay-cli/surface.test.ts | 39 ++++- 6 files changed, 281 insertions(+), 25 deletions(-) create mode 100644 packages/cli/scripts/install.test.js diff --git a/packages/cli/scripts/install.js b/packages/cli/scripts/install.js index 50ac58fe..8e4efc70 100644 --- a/packages/cli/scripts/install.js +++ b/packages/cli/scripts/install.js @@ -2,9 +2,16 @@ // postinstall: put a runnable relayfile binary in this package's bin/. // -// The platform mapping, binary file names, and source-checkout detection come -// from @relayfile/sdk/relay-cli, the same module run.js and `agent-relay file` -// use, so "which binary is this host's" is decided in exactly one place. +// The platform mapping and binary file names come from @relayfile/sdk/relay-cli, +// the same module run.js and `agent-relay file` use, so "which binary is this +// host's" is decided in exactly one place. +// +// Source-checkout detection is the one thing that cannot come from there. This +// script runs during `npm install`, and in a fresh clone that is before +// packages/sdk/typescript/dist exists — importing the SDK to decide whether to +// skip would fail the install before it could skip. So the two checkout markers +// are tested locally, ahead of any SDK load. `install.test.js` pins this +// predicate against the SDK's `findSourceCheckoutRoot`. const fs = require("fs"); const path = require("path"); @@ -28,6 +35,33 @@ async function loadRelayCli() { } } +/** + * Locate a relayfile source checkout above `start`: a directory with both + * `go.mod` and `cmd/relayfile-cli`. + * + * Same two markers as `findSourceCheckoutRoot` in @relayfile/sdk/relay-cli, + * duplicated here only because this runs before the SDK is built. + * + * @param {string} start - Directory to search upward from. + * @returns {string|null} The checkout root, or null when there is none. + */ +function findSourceCheckoutRoot(start) { + let current = path.resolve(start); + for (;;) { + if ( + fs.existsSync(path.join(current, "go.mod")) && + fs.existsSync(path.join(current, "cmd", "relayfile-cli")) + ) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + function getDownloadUrl(packagedBinaryName) { return `https://github.com/AgentWorkforce/relayfile/releases/download/v${VERSION}/${packagedBinaryName}`; } @@ -56,8 +90,18 @@ function download(url, dest) { } async function main() { - const { genericBinaryName, platformBinaryName, findSourceCheckoutRoot } = - await loadRelayCli(); + // Before the SDK is touched: a fresh clone has no SDK dist/, and failing + // here would break `npm install` for the whole repo. + if (findSourceCheckoutRoot(__dirname)) { + // run.js falls back to `go run ./cmd/relayfile-cli` in a checkout, so the + // command still works without a downloaded binary. + console.log( + "Skipping relayfile binary install in source checkout; run npm run build --workspace=packages/cli to build package binaries." + ); + return; + } + + const { genericBinaryName, platformBinaryName } = await loadRelayCli(); const packagedBinaryName = platformBinaryName(); if (!packagedBinaryName) { @@ -75,15 +119,6 @@ async function main() { return; } - if (findSourceCheckoutRoot(__dirname)) { - // run.js falls back to `go run ./cmd/relayfile-cli` in a checkout, so the - // command still works without a downloaded binary. - console.log( - "Skipping relayfile binary install in source checkout; run npm run build --workspace=packages/cli to build package binaries." - ); - return; - } - const packagedBinPath = path.join(BIN_DIR, packagedBinaryName); if (fs.existsSync(packagedBinPath)) { diff --git a/packages/cli/scripts/install.test.js b/packages/cli/scripts/install.test.js new file mode 100644 index 00000000..f2a10eb9 --- /dev/null +++ b/packages/cli/scripts/install.test.js @@ -0,0 +1,140 @@ +"use strict"; + +// postinstall runs during `npm install`, which in a fresh clone is before +// packages/sdk/typescript/dist exists. It shipped once importing +// @relayfile/sdk/relay-cli before it could detect the checkout and skip, so a +// clone with no built SDK died in postinstall and `npm install` never +// completed. +// +// These tests run the real script inside a directory shaped like a fresh +// clone: the two checkout markers, no node_modules, no SDK dist anywhere above +// it. Nothing is stubbed, and the host checkout cannot make them pass. + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const test = require("node:test"); + +const installScript = path.join(__dirname, "install.js"); + +/** + * Build a throwaway tree holding a copy of install.js. + * + * @param {string} label - Included in the directory name. + * @param {{ goMod?: boolean, cmdDir?: boolean }} markers - Which checkout + * markers to create at the root. + * @returns {{ root: string, script: string }} The tree root and the script + * copy to run. + */ +function fakeClone(label, markers = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `relayfile-install-${label}-`)); + const scripts = path.join(root, "packages", "cli", "scripts"); + fs.mkdirSync(scripts, { recursive: true }); + + if (markers.goMod !== false) { + fs.writeFileSync(path.join(root, "go.mod"), "module github.com/example/relayfile\n"); + } + if (markers.cmdDir !== false) { + fs.mkdirSync(path.join(root, "cmd", "relayfile-cli"), { recursive: true }); + } + + const script = path.join(scripts, "install.js"); + fs.copyFileSync(installScript, script); + fs.writeFileSync( + path.join(root, "packages", "cli", "package.json"), + JSON.stringify({ name: "relayfile", version: "0.0.0-test" }, null, 2), + ); + + // A fresh clone's state: no node_modules, so no @relayfile/sdk to import. + assert.equal(fs.existsSync(path.join(root, "node_modules")), false); + + return { root, script }; +} + +/** + * Run a copied install.js with the caller's environment sealed off. + * + * `PATH` is emptied so nothing can shell out, and the temp root is the cwd, so + * the only module resolution paths are inside the fake clone. + * + * @param {string} script - The copied script. + * @param {string} root - Its tree root, used as cwd. + * @returns {{ status: number, stdout: string, stderr: string }} The result. + */ +function runInstall(script, root) { + const result = spawnSync(process.execPath, [script], { + cwd: root, + encoding: "utf8", + timeout: 60000, + env: { PATH: "", HOME: root }, + }); + if (result.error) { + throw result.error; + } + return { + status: result.status, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; +} + +test("skips the download in a source checkout with no SDK dist", () => { + // The bug: install.js imported @relayfile/sdk/relay-cli before this check, + // so this exited 1 and took `npm install` down with it. + const { root, script } = fakeClone("fresh-clone"); + const result = runInstall(script, root); + + assert.equal(result.status, 0, `${result.stdout}${result.stderr}`); + assert.match(result.stdout, /Skipping relayfile binary install in source checkout/); + assert.doesNotMatch(result.stderr, /could not be loaded/); + // It must not have reached the download path either. + assert.doesNotMatch(result.stdout, /Downloading relayfile/); +}); + +test("creates no bin/ directory when it skips", () => { + // The skip happens before any filesystem setup, so a clone stays clean. + const { root, script } = fakeClone("no-bin"); + const result = runInstall(script, root); + + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.existsSync(path.join(root, "packages", "cli", "bin")), false); +}); + +test("requires both checkout markers before skipping", () => { + // go.mod alone is some other Go project, not a relayfile checkout. Without + // the SDK there is nothing to fall back to, so it must report the SDK load + // failure rather than silently skip a real install. + for (const markers of [{ cmdDir: false }, { goMod: false }]) { + const { root, script } = fakeClone("partial", markers); + const result = runInstall(script, root); + + assert.equal(result.status, 1, `${result.stdout}${result.stderr}`); + assert.match(result.stderr, /@relayfile\/sdk\/relay-cli could not be loaded/); + assert.doesNotMatch(result.stdout, /Skipping relayfile binary install/); + } +}); + +test("detects a checkout the same way the SDK does", async () => { + // install.js carries its own copy of the predicate because it runs before + // the SDK is built. This pins the copy to the original. + const { findSourceCheckoutRoot } = await import("@relayfile/sdk/relay-cli"); + const { root, script } = fakeClone("parity"); + + assert.equal(findSourceCheckoutRoot(path.dirname(script)), root); + assert.equal(findSourceCheckoutRoot(os.tmpdir()), null); + + const partial = fakeClone("parity-partial", { cmdDir: false }); + assert.equal(findSourceCheckoutRoot(path.dirname(partial.script)), null); +}); + +test("the checkout skip precedes the SDK import", () => { + // Order is the whole fix: a source-checkout skip that needs the SDK built + // cannot run on a fresh clone. + const source = fs.readFileSync(installScript, "utf8"); + const skip = source.indexOf("findSourceCheckoutRoot(__dirname)"); + const load = source.indexOf("await loadRelayCli()"); + assert.ok(skip !== -1 && load !== -1); + assert.ok(skip < load, "the source-checkout skip must come before loadRelayCli()"); +}); diff --git a/packages/sdk/typescript/src/relay-cli/index.ts b/packages/sdk/typescript/src/relay-cli/index.ts index cdada308..7431b318 100644 --- a/packages/sdk/typescript/src/relay-cli/index.ts +++ b/packages/sdk/typescript/src/relay-cli/index.ts @@ -140,14 +140,20 @@ export function relayfileCommands(): readonly RelayCliCommandSpec[] { /** * Every name and alias the relayfile binary routes at the top level. * - * Includes `help` and `__command-spec`, which the binary routes but keeps out - * of its published surface (the host renders help itself, and - * `__command-spec` is the introspection hook that produces the snapshot). + * Includes three tokens the binary routes outside its command table, and so + * keeps out of its published surface: `help` (the host renders help itself), + * `__command-spec` (the introspection hook that produces the snapshot), and + * `version` (handled by the binary's `wantsVersion`, the same path as + * `--version`). They are routable here but deliberately not declared in + * `commands`: that tree is a generated snapshot of the Go command table, and + * the binary's own usage does not advertise `version` either, so declaring it + * would make `agent-relay file --help` claim a command `relayfile --help` + * does not. * * @returns The routable top-level tokens. */ export function routableTopLevelNames(): readonly string[] { - const names = new Set(["help", "__command-spec"]) + const names = new Set(["help", "__command-spec", "version"]) for (const command of relayfileCommands()) { names.add(command.name) for (const alias of command.aliases ?? []) { diff --git a/packages/sdk/typescript/src/relay-cli/resolve-binary.test.ts b/packages/sdk/typescript/src/relay-cli/resolve-binary.test.ts index c7f600e8..2e524b31 100644 --- a/packages/sdk/typescript/src/relay-cli/resolve-binary.test.ts +++ b/packages/sdk/typescript/src/relay-cli/resolve-binary.test.ts @@ -250,6 +250,45 @@ describe("resolveRelayfileBinary", () => { ).toMatchObject({ kind: "binary", command: released }) }) + it("finds the .exe those builds write in a Windows checkout", () => { + // `go build -o bin/relayfile-cli` appends `.exe` for GOOS=windows, and the + // dist name carries it too. Without the suffix a successful `make build` + // is invisible and resolution falls through to `go run`. + const repoRoot = path.join("/work", "relayfile") + const checkout = [ + path.join(repoRoot, "go.mod"), + path.join(repoRoot, "cmd", "relayfile-cli") + ] + const madeBinary = path.join(repoRoot, "bin", "relayfile-cli.exe") + expect( + resolve({ + binDirs: [], + searchFrom: [repoRoot], + platform: "win32", + arch: "x64", + fileExists: fakeFs([...checkout, madeBinary]) + }) + ).toMatchObject({ kind: "binary", command: madeBinary }) + + // The dist name is the one build-cli-npm-packages.mjs looks for, which is + // exactly `platformBinaryName`. + const released = path.join( + repoRoot, + "dist", + platformBinaryName("win32", "x64") ?? "" + ) + expect(path.basename(released)).toBe("relayfile-cli-windows-amd64.exe") + expect( + resolve({ + binDirs: [], + searchFrom: [repoRoot], + platform: "win32", + arch: "x64", + fileExists: fakeFs([...checkout, released]) + }) + ).toMatchObject({ kind: "binary", command: released }) + }) + it("runs from Go source in a checkout when no binary is built", () => { // postinstall intentionally skips building the binary in a source // checkout; without this fallback the installed command is unusable there. diff --git a/packages/sdk/typescript/src/relay-cli/resolve-binary.ts b/packages/sdk/typescript/src/relay-cli/resolve-binary.ts index 42d6ba9f..4325e07b 100644 --- a/packages/sdk/typescript/src/relay-cli/resolve-binary.ts +++ b/packages/sdk/typescript/src/relay-cli/resolve-binary.ts @@ -360,6 +360,11 @@ function defaultResolveFrom(): readonly string[] { /** * `make build` and `make release` outputs inside a source checkout. * + * Both names carry `.exe` on Windows: `go build -o bin/relayfile-cli` appends + * it for GOOS=windows, and `scripts/build-cli-npm-packages.mjs` looks for + * `dist/relayfile-cli-windows-.exe`. Without the suffix a successful + * `make build` is invisible here and resolution falls through to `go run`. + * * @returns Candidate binary paths, highest priority first. */ function sourceCheckoutBinaries( @@ -376,9 +381,11 @@ function sourceCheckoutBinaries( continue } seenRoots.add(root) - candidates.push(path.join(root, "bin", "relayfile-cli")) + const goOs = PLATFORM_MAP[platform] ?? platform + const extension = goOs === "windows" ? ".exe" : "" + candidates.push(path.join(root, "bin", `relayfile-cli${extension}`)) candidates.push( - path.join(root, "dist", `relayfile-cli-${PLATFORM_MAP[platform] ?? platform}-${goArch(arch)}`) + path.join(root, "dist", `relayfile-cli-${goOs}-${goArch(arch)}${extension}`) ) } return candidates diff --git a/packages/sdk/typescript/src/relay-cli/surface.test.ts b/packages/sdk/typescript/src/relay-cli/surface.test.ts index ea19095b..83b80d1b 100644 --- a/packages/sdk/typescript/src/relay-cli/surface.test.ts +++ b/packages/sdk/typescript/src/relay-cli/surface.test.ts @@ -7,7 +7,11 @@ import { type RelayCliSurface } from "@agent-relay/cli-surface" -import { createRelayCliSurface, relayfileCommands } from "./index.js" +import { + createRelayCliSurface, + relayfileCommands, + routableTopLevelNames +} from "./index.js" import { buildRelayfileBinary } from "./testing/build-binary.js" /** @@ -106,11 +110,14 @@ describe("command tree drift", () => { declared.add(alias) } } - // `help` and `__command-spec` are the two documented exceptions: the host - // renders help itself, and `__command-spec` is the introspection hook that - // produces the snapshot. - for (const routable of ["help", "__command-spec"]) { + // `help`, `__command-spec`, and `version` are the documented exceptions: + // the binary routes all three outside its command table, so they are + // routable without being declared. The host renders help itself, + // `__command-spec` is the introspection hook that produces the snapshot, + // and `version` is `wantsVersion`'s alias for `--version`. + for (const routable of ["help", "__command-spec", "version"]) { expect(declared.has(routable)).toBe(false) + expect(routableTopLevelNames()).toContain(routable) } }, 60_000) @@ -134,6 +141,28 @@ describe("run", () => { expect(version.stderr).toBe("") }) + it("routes a bare `version` token the way the binary does", async () => { + // The binary's `wantsVersion` accepts `version` as well as `--version`, so + // `agent-relay file version` must reach it rather than trip the surface's + // own unknown-command guard. + const spelled = await invoke(["version"]) + const flagged = await invoke(["--version"]) + expect(spelled.code).toBe(0) + expect(spelled.stdout).toBe(flagged.stdout) + expect(spelled.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/) + expect(spelled.stderr).toBe("") + }) + + it("lets the binary reject `version` with arguments", async () => { + // `wantsVersion` only matches a lone `version`, so `version --json` falls + // through to the binary's dispatcher. The surface must not pre-empt that + // with its own exit 2: the error has to come from the binary. + const result = await invoke(["version", "--json"]) + expect(result.code).not.toBe(0) + expect(result.stderr).toContain("unknown subcommand") + expect(result.stderr).not.toContain('unknown command "version"') + }, 30_000) + it("returns a non-zero code from a real failure", async () => { // `workspace use` with a workspace that cannot exist fails inside the // binary; the surface must surface its code, not swallow it. From 714bdb13dd6965f1e975ee4c31e9e4f14d6ec56a Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Thu, 17 Sep 2026 16:32:50 -0700 Subject: [PATCH 07/14] chore(trail): record PR #507 findings trajectory Co-Authored-By: Claude Opus 5 (1M context) Session-Id: 7377ec91-f877-4cd3-863f-29d31e4de625 --- .../completed/2026-09/traj_brbbw28655f0.json | 91 ++++++++++++ .../completed/2026-09/traj_brbbw28655f0.md | 49 ++++++ .../2026-09/traj_brbbw28655f0.trace.json | 140 ++++++++++++++++++ .trajectories/index.json | 9 +- 4 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 .trajectories/completed/2026-09/traj_brbbw28655f0.json create mode 100644 .trajectories/completed/2026-09/traj_brbbw28655f0.md create mode 100644 .trajectories/completed/2026-09/traj_brbbw28655f0.trace.json diff --git a/.trajectories/completed/2026-09/traj_brbbw28655f0.json b/.trajectories/completed/2026-09/traj_brbbw28655f0.json new file mode 100644 index 00000000..38cea96a --- /dev/null +++ b/.trajectories/completed/2026-09/traj_brbbw28655f0.json @@ -0,0 +1,91 @@ +{ + "id": "traj_brbbw28655f0", + "version": 1, + "task": { + "title": "Fix Bugbot findings on PR #507 relay-cli surface", + "source": { + "system": "plain", + "id": "PR-507" + } + }, + "status": "completed", + "startedAt": "2026-09-17T23:29:02.805Z", + "completedAt": "2026-09-17T23:32:47.819Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-09-17T23:32:33.749Z" + } + ], + "chapters": [ + { + "id": "chap_qtshte0j3hg0", + "title": "Work", + "agentName": "default", + "startedAt": "2026-09-17T23:32:33.749Z", + "endedAt": "2026-09-17T23:32:47.819Z", + "events": [ + { + "ts": 1789687953750, + "type": "decision", + "content": "Route `version` in the surface without declaring it in the command tree: Route `version` in the surface without declaring it in the command tree", + "raw": { + "question": "Route `version` in the surface without declaring it in the command tree", + "chosen": "Route `version` in the surface without declaring it in the command tree", + "alternatives": [], + "reasoning": "The Go binary handles `version` in wantsVersion() outside its command table and does not list it in printUsage, so the generated command-spec snapshot must not carry it; declaring it would need a new Go command or a hand-edit that check:command-spec flags as drift" + }, + "significance": "high" + }, + { + "ts": 1789687953872, + "type": "decision", + "content": "Duplicate source-checkout detection inside packages/cli/scripts/install.js: Duplicate source-checkout detection inside packages/cli/scripts/install.js", + "raw": { + "question": "Duplicate source-checkout detection inside packages/cli/scripts/install.js", + "chosen": "Duplicate source-checkout detection inside packages/cli/scripts/install.js", + "alternatives": [], + "reasoning": "postinstall runs before packages/sdk/typescript/dist exists on a fresh clone, so it cannot import findSourceCheckoutRoot from the SDK to decide to skip; install.test.js pins the local copy against the SDK's implementation" + }, + "significance": "high" + }, + { + "ts": 1789687954013, + "type": "decision", + "content": "Kept RELAY_CLI_EXIT_BINARY_NOT_FOUND (127) rather than mirroring the shim's exit 1: Kept RELAY_CLI_EXIT_BINARY_NOT_FOUND (127) rather than mirroring the shim's exit 1", + "raw": { + "question": "Kept RELAY_CLI_EXIT_BINARY_NOT_FOUND (127) rather than mirroring the shim's exit 1", + "chosen": "Kept RELAY_CLI_EXIT_BINARY_NOT_FOUND (127) rather than mirroring the shim's exit 1", + "alternatives": [], + "reasoning": "Finding 2 was already fixed on the branch; 127 is a documented constant that lets a host distinguish 'relayfile is not installed here' from 'relayfile ran and failed', and clean-install.test.ts already asserts it" + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Fixed 3 of 4 PR #507 review findings (version routing, Windows .exe checkout lookup, postinstall source-checkout skip); finding 2 was already fixed on the branch", + "approach": "Standard approach", + "confidence": 0.85 + }, + "commits": [ + "50cf4d5c" + ], + "filesChanged": [ + "packages/cli/scripts/install.js", + "packages/cli/scripts/install.test.js", + "packages/sdk/typescript/src/relay-cli/index.ts", + "packages/sdk/typescript/src/relay-cli/resolve-binary.test.ts", + "packages/sdk/typescript/src/relay-cli/resolve-binary.ts", + "packages/sdk/typescript/src/relay-cli/surface.test.ts" + ], + "projectId": "AgentWorkforce/relayfile", + "tags": [], + "_trace": { + "startRef": "048ff2b994f6c7053d6d7048d78d8237a78524bc", + "endRef": "50cf4d5c278745e8efe8163a73b04e58175184cb", + "traceId": "20c6b85e-108f-45b5-861d-e410d3f81c08" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-09/traj_brbbw28655f0.md b/.trajectories/completed/2026-09/traj_brbbw28655f0.md new file mode 100644 index 00000000..0716e225 --- /dev/null +++ b/.trajectories/completed/2026-09/traj_brbbw28655f0.md @@ -0,0 +1,49 @@ +# Trajectory: Fix Bugbot findings on PR #507 relay-cli surface + +> **Status:** ✅ Completed +> **Task:** PR-507 +> **Confidence:** 85% +> **Started:** September 17, 2026 at 04:29 PM +> **Completed:** September 17, 2026 at 04:32 PM + +--- + +## Summary + +Fixed 3 of 4 PR #507 review findings (version routing, Windows .exe checkout lookup, postinstall source-checkout skip); finding 2 was already fixed on the branch + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Route `version` in the surface without declaring it in the command tree +- **Chose:** Route `version` in the surface without declaring it in the command tree +- **Reasoning:** The Go binary handles `version` in wantsVersion() outside its command table and does not list it in printUsage, so the generated command-spec snapshot must not carry it; declaring it would need a new Go command or a hand-edit that check:command-spec flags as drift + +### Duplicate source-checkout detection inside packages/cli/scripts/install.js +- **Chose:** Duplicate source-checkout detection inside packages/cli/scripts/install.js +- **Reasoning:** postinstall runs before packages/sdk/typescript/dist exists on a fresh clone, so it cannot import findSourceCheckoutRoot from the SDK to decide to skip; install.test.js pins the local copy against the SDK's implementation + +### Kept RELAY_CLI_EXIT_BINARY_NOT_FOUND (127) rather than mirroring the shim's exit 1 +- **Chose:** Kept RELAY_CLI_EXIT_BINARY_NOT_FOUND (127) rather than mirroring the shim's exit 1 +- **Reasoning:** Finding 2 was already fixed on the branch; 127 is a documented constant that lets a host distinguish 'relayfile is not installed here' from 'relayfile ran and failed', and clean-install.test.ts already asserts it + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Route `version` in the surface without declaring it in the command tree: Route `version` in the surface without declaring it in the command tree +- Duplicate source-checkout detection inside packages/cli/scripts/install.js: Duplicate source-checkout detection inside packages/cli/scripts/install.js +- Kept RELAY_CLI_EXIT_BINARY_NOT_FOUND (127) rather than mirroring the shim's exit 1: Kept RELAY_CLI_EXIT_BINARY_NOT_FOUND (127) rather than mirroring the shim's exit 1 + +--- + +## Artifacts + +**Commits:** 50cf4d5c +**Files changed:** 6 diff --git a/.trajectories/completed/2026-09/traj_brbbw28655f0.trace.json b/.trajectories/completed/2026-09/traj_brbbw28655f0.trace.json new file mode 100644 index 00000000..041d771c --- /dev/null +++ b/.trajectories/completed/2026-09/traj_brbbw28655f0.trace.json @@ -0,0 +1,140 @@ +{ + "version": "1.0.0", + "id": "20c6b85e-108f-45b5-861d-e410d3f81c08", + "timestamp": "2026-09-17T23:32:47.839Z", + "trajectory": "traj_brbbw28655f0", + "files": [ + { + "path": "packages/cli/scripts/install.js", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 2, + "end_line": 17, + "revision": "50cf4d5c278745e8efe8163a73b04e58175184cb" + }, + { + "start_line": 35, + "end_line": 67, + "revision": "50cf4d5c278745e8efe8163a73b04e58175184cb" + }, + { + "start_line": 90, + "end_line": 107, + "revision": "50cf4d5c278745e8efe8163a73b04e58175184cb" + }, + { + "start_line": 119, + "end_line": 124, + "revision": "50cf4d5c278745e8efe8163a73b04e58175184cb" + } + ] + } + ] + }, + { + "path": "packages/cli/scripts/install.test.js", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 140, + "revision": "50cf4d5c278745e8efe8163a73b04e58175184cb" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/index.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 140, + "end_line": 159, + "revision": "50cf4d5c278745e8efe8163a73b04e58175184cb" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/resolve-binary.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 250, + "end_line": 294, + "revision": "50cf4d5c278745e8efe8163a73b04e58175184cb" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/resolve-binary.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 360, + "end_line": 370, + "revision": "50cf4d5c278745e8efe8163a73b04e58175184cb" + }, + { + "start_line": 381, + "end_line": 391, + "revision": "50cf4d5c278745e8efe8163a73b04e58175184cb" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/surface.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 7, + "end_line": 17, + "revision": "50cf4d5c278745e8efe8163a73b04e58175184cb" + }, + { + "start_line": 110, + "end_line": 123, + "revision": "50cf4d5c278745e8efe8163a73b04e58175184cb" + }, + { + "start_line": 141, + "end_line": 168, + "revision": "50cf4d5c278745e8efe8163a73b04e58175184cb" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.trajectories/index.json b/.trajectories/index.json index dc0042ca..83072784 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-09-17T22:42:19.981Z", + "lastUpdated": "2026-09-17T23:32:47.873Z", "trajectories": { "traj_4pvrlmqfnzng": { "title": "Review PR #278 in AgentWorkforce/relayfile", @@ -386,6 +386,13 @@ "startedAt": "2026-09-17T22:41:57.895Z", "completedAt": "2026-09-17T22:42:19.937Z", "path": "/home/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/completed/2026-09/traj_r0q3qwvib91g.json" + }, + "traj_brbbw28655f0": { + "title": "Fix Bugbot findings on PR #507 relay-cli surface", + "status": "completed", + "startedAt": "2026-09-17T23:29:02.805Z", + "completedAt": "2026-09-17T23:32:47.819Z", + "path": "/home/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/completed/2026-09/traj_brbbw28655f0.json" } } } \ No newline at end of file From f89e95460932c136a3597a90d91a0994dbd9242c Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Thu, 17 Sep 2026 23:03:28 -0700 Subject: [PATCH 08/14] chore(sdk): resolve @agent-relay/cli-surface from the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The devDependency resolved through a link to a sibling checkout, so CI could not load the surface at all. Relay's 12.2.4 release publishes a working tarball — the earlier 12.2.2 shipped only package.json because `files` is ["dist","README.md"] and the publish ran with ignore-scripts — so this pins ^12.2.4 and records a registry tarball with an integrity hash. This clears the SDK Typecheck and Client Typecheck failures. It does NOT make `npm ci` pass here: the six @relayfile/cli-* optionalDependencies are still unpublished, and npm refuses a lockfile it cannot resolve regardless of optionality. That needs the relayfile release, which is its own ordering problem — the packages only exist on this branch, so the release that publishes them has to run after this merges. 97 SDK tests passing. Co-Authored-By: Claude Opus 5 (1M context) Session-Id: d458bd97-53d8-4f02-be9c-48b67b93c916 --- package-lock.json | 23 ++++++++--------------- packages/sdk/typescript/package.json | 2 +- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 30c7a804..c028ed31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,18 +25,6 @@ "tsx": "^4.21.0" } }, - "../relay/packages/cli-surface": { - "name": "@agent-relay/cli-surface", - "version": "12.2.2", - "dev": true, - "devDependencies": { - "@types/node": "^22.19.3", - "vitest": "^4.1.0" - }, - "engines": { - "node": ">=22.0.0" - } - }, "node_modules/@agent-assistant/connectivity": { "version": "0.2.24", "resolved": "https://registry.npmjs.org/@agent-assistant/connectivity/-/connectivity-0.2.24.tgz", @@ -483,8 +471,13 @@ ] }, "node_modules/@agent-relay/cli-surface": { - "resolved": "../relay/packages/cli-surface", - "link": true + "version": "12.2.4", + "resolved": "https://registry.npmjs.org/@agent-relay/cli-surface/-/cli-surface-12.2.4.tgz", + "integrity": "sha512-DqJXout26UOLNXi+yTgAD53ek9TXGoSw+5LX8wRwqWeiItiBdw5oHE54UeIzjdbVxNZyk58Dw1494goHN/+AUg==", + "dev": true, + "engines": { + "node": ">=22.0.0" + } }, "node_modules/@agent-relay/config": { "version": "4.0.28", @@ -8505,7 +8498,7 @@ "tar": "^7.5.10" }, "devDependencies": { - "@agent-relay/cli-surface": "^12.2.2", + "@agent-relay/cli-surface": "^12.2.4", "typescript": "^5.7.3", "vitest": "^3.0.0" }, diff --git a/packages/sdk/typescript/package.json b/packages/sdk/typescript/package.json index eaf8d8f0..86ee5b60 100644 --- a/packages/sdk/typescript/package.json +++ b/packages/sdk/typescript/package.json @@ -83,7 +83,7 @@ "@relayfile/mount-linux-x64": "0.10.56" }, "devDependencies": { - "@agent-relay/cli-surface": "^12.2.2", + "@agent-relay/cli-surface": "^12.2.4", "typescript": "^5.7.3", "vitest": "^3.0.0" }, From c1751c420e45219ce6267237b4afcc12ccde46e3 Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Thu, 17 Sep 2026 23:29:31 -0700 Subject: [PATCH 09/14] fix(cli-surface): run source fallback from the caller's cwd, close spec drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of Devin's four findings on #507; the fourth was already fixed. `go run` launches the program with the go command's own working directory, and go only finds the module from the checkout — so the source fallback ran relayfile from the repository, and every relative path in argv (`--output report.json`) resolved there instead of where the caller actually was. An absolute package path fails outside a module and `go -C run` moves the child too, so the fix is to build first and spawn the result: buildGoRunBinary builds into a temp path (never the working tree) and both entry points — the mounted surface and the `relayfile` bin shim — then spawn it with the caller's cwd inherited. The command table also drifted from what the binary parses, in the two ways the table was supposed to make impossible: - `listen` reads its first positional as the workspace and `dev` forwards argv to it, but neither declared an arg, so a host routing from the emitted spec refused `agent-relay file listen my-workspace`. Declared on both, and on `workspace status`, which the new guard caught doing the same. - `supervisor install` advertised `--interval`, which runListen has never registered. It embeds its argv into the unit's ExecStart as `relayfile listen ...` under Restart=on-failure, so that flag installed a service that exited on every start, forever. It now declares runListen's flags. Both had a blind spot in the AST drift test rather than bad luck: TestOptionsMatchSourceFlagSets exempted `supervisor install` outright (isPassThroughCommand), and nothing checked positionals at all. The exemption is gone and TestDeclaredArgsCoverSourcePositionals asserts that a command whose parser reads fs.Arg/fs.Args declares a positional. Co-Authored-By: Claude Opus 5 (1M context) Session-Id: 6a85a22c-13e0-4844-a610-fabfeb3fc126 --- .trajectories/active/traj_3lyio30tipf8.json | 86 ++++++++ .trajectories/index.json | 8 +- cmd/relayfile-cli/commandspec.go | 20 +- cmd/relayfile-cli/commandspec_test.go | 72 ++++++- cmd/relayfile-cli/listen_test.go | 112 +++++++++++ packages/cli/scripts/run.js | 37 ++-- packages/cli/scripts/run.test.js | 11 ++ .../src/relay-cli/command-spec.json | 60 +++++- .../src/relay-cli/command-spec.test.ts | 30 +++ .../src/relay-cli/go-run-cwd.test.ts | 187 ++++++++++++++++++ .../sdk/typescript/src/relay-cli/index.ts | 43 +++- .../src/relay-cli/resolve-binary.ts | 110 ++++++++++- 12 files changed, 735 insertions(+), 41 deletions(-) create mode 100644 .trajectories/active/traj_3lyio30tipf8.json create mode 100644 packages/sdk/typescript/src/relay-cli/go-run-cwd.test.ts diff --git a/.trajectories/active/traj_3lyio30tipf8.json b/.trajectories/active/traj_3lyio30tipf8.json new file mode 100644 index 00000000..bc76a5b4 --- /dev/null +++ b/.trajectories/active/traj_3lyio30tipf8.json @@ -0,0 +1,86 @@ +{ + "id": "traj_3lyio30tipf8", + "version": 1, + "task": { + "title": "Fix four Devin findings on relayfile PR #507 (CLI surface)", + "source": { + "system": "plain", + "id": "PR-507" + } + }, + "status": "active", + "startedAt": "2026-09-18T06:29:04.757Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-09-18T06:29:15.251Z" + } + ], + "chapters": [ + { + "id": "chap_s7t4qhsbqzyp", + "title": "Work", + "agentName": "default", + "startedAt": "2026-09-18T06:29:15.251Z", + "events": [ + { + "ts": 1789712955251, + "type": "decision", + "content": "Fix the go-run cwd bug by building then executing, in the shared resolver: Fix the go-run cwd bug by building then executing, in the shared resolver", + "raw": { + "question": "Fix the go-run cwd bug by building then executing, in the shared resolver", + "chosen": "Fix the go-run cwd bug by building then executing, in the shared resolver", + "alternatives": [], + "reasoning": "go run gives the launched program the go command's own working directory, and go only finds the module from the checkout. I verified both escape hatches fail: an absolute package path outside a module errors ('go.mod file not found'), and 'go -C run' hands the child that same dir. Building to a temp path (not the checkout's bin/, so a working tree is never written to) and spawning the binary with the caller's cwd is the only way to separate the two. Put it in resolve-binary.ts so the CLI shim and the mounted surface share one implementation." + }, + "significance": "high" + }, + { + "ts": 1789712955373, + "type": "decision", + "content": "Declare listen's workspace positional on listen, dev and workspace status: Declare listen's workspace positional on listen, dev and workspace status", + "raw": { + "question": "Declare listen's workspace positional on listen, dev and workspace status", + "chosen": "Declare listen's workspace positional on listen, dev and workspace status", + "alternatives": [], + "reasoning": "runListen reads fs.Arg(0) as the workspace and dev forwards argv to it verbatim; the host builds its parser from the emitted spec, so an undeclared positional is a rejected-but-valid invocation. workspace status is the same bug, surfaced by the new AST guard rather than by the review." + }, + "significance": "high" + }, + { + "ts": 1789712955516, + "type": "decision", + "content": "Give supervisor install flagSource runListen instead of deleting --interval: Give supervisor install flagSource runListen instead of deleting --interval", + "raw": { + "question": "Give supervisor install flagSource runListen instead of deleting --interval", + "chosen": "Give supervisor install flagSource runListen instead of deleting --interval", + "alternatives": [], + "reasoning": "The drift test exempted 'supervisor install' from the flag check (isPassThroughCommand) precisely because it declared options with no flagSource — that exemption is why --interval survived. Removing the exemption and pointing flagSource at runListen makes the table's claim checkable: supervisor install embeds its argv into ExecStart as 'relayfile listen ...', so its options are listen's options, no more and no less. Deleting --interval alone would have left it under-declaring the flags it really forwards." + }, + "significance": "high" + }, + { + "ts": 1789712960149, + "type": "decision", + "content": "Rejected finding 1 (binary stdout) as already fixed: Rejected finding 1 (binary stdout) as already fixed", + "raw": { + "question": "Rejected finding 1 (binary stdout) as already fixed", + "chosen": "Rejected finding 1 (binary stdout) as already fixed", + "alternatives": [], + "reasoning": "setEncoding was removed in af70448f on this same branch, before the review round. I mutation-checked the covering test (binary-output.test.ts) by reintroducing setEncoding: it fails on the 0xff 0xfe payload, so the guard is real, not vacuous. No change made." + }, + "significance": "high" + } + ] + } + ], + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relayfile", + "tags": [], + "_trace": { + "startRef": "f89e95460932c136a3597a90d91a0994dbd9242c", + "endRef": "f89e95460932c136a3597a90d91a0994dbd9242c" + } +} \ No newline at end of file diff --git a/.trajectories/index.json b/.trajectories/index.json index 83072784..4d015916 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-09-17T23:32:47.873Z", + "lastUpdated": "2026-09-18T06:29:20.150Z", "trajectories": { "traj_4pvrlmqfnzng": { "title": "Review PR #278 in AgentWorkforce/relayfile", @@ -393,6 +393,12 @@ "startedAt": "2026-09-17T23:29:02.805Z", "completedAt": "2026-09-17T23:32:47.819Z", "path": "/home/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/completed/2026-09/traj_brbbw28655f0.json" + }, + "traj_3lyio30tipf8": { + "title": "Fix four Devin findings on relayfile PR #507 (CLI surface)", + "status": "active", + "startedAt": "2026-09-18T06:29:04.757Z", + "path": "/home/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/active/traj_3lyio30tipf8.json" } } } \ No newline at end of file diff --git a/cmd/relayfile-cli/commandspec.go b/cmd/relayfile-cli/commandspec.go index 469c3fcd..66decb71 100644 --- a/cmd/relayfile-cli/commandspec.go +++ b/cmd/relayfile-cli/commandspec.go @@ -264,6 +264,7 @@ func relayfileCommands() []cliCommandSpec { Name: "status", Description: "Show sync status for a workspace", flagSource: "runWorkspaceStatus", + Args: []cliArgSpec{workspaceArg}, Options: []cliOptionSpec{workspaceFlagOption, jsonFlagOption}, }, { @@ -623,12 +624,15 @@ func relayfileCommands() []cliCommandSpec { { Name: "install", Description: "Install the auto-restart service for a workspace mount", - Args: []cliArgSpec{workspaceArg}, - Options: []cliOptionSpec{ - // supervisor install forwards its argv to - // `relayfile listen`, which owns these flags. - {Flags: "--interval ", Description: "sync interval passed through to the supervised listen process"}, - }, + // supervisor install embeds its argv verbatim into the + // unit's ExecStart as `relayfile listen ...`, so it + // accepts exactly what runListen parses and nothing else: + // a flag declared here that runListen does not register + // installs a service that exits on every start and, under + // Restart=on-failure, restarts forever. + flagSource: "runListen", + Args: []cliArgSpec{workspaceArg}, + Options: listenOptions(), }, { Name: "uninstall", @@ -769,6 +773,7 @@ func relayfileCommands() []cliCommandSpec { Description: "Stream workspace file events, optionally running a command per event", Aliases: []string{"watch"}, flagSource: "runListen", + Args: []cliArgSpec{workspaceArg}, Options: listenOptions(), dispatch: func(inv cliInvocation) error { return runListen(inv.args, inv.stdout) @@ -797,8 +802,9 @@ func relayfileCommands() []cliCommandSpec { Description: "Print workspace context, then stream file events like `listen`", Hidden: true, // dev forwards its argv verbatim to runListen, so it accepts - // exactly listen's flags. + // exactly listen's flags and the same optional workspace. flagSource: "runListen", + Args: []cliArgSpec{workspaceArg}, Options: listenOptions(), dispatch: func(inv cliInvocation) error { return runDev(inv.args, inv.stdin, inv.stdout) diff --git a/cmd/relayfile-cli/commandspec_test.go b/cmd/relayfile-cli/commandspec_test.go index 55970cc0..7486be36 100644 --- a/cmd/relayfile-cli/commandspec_test.go +++ b/cmd/relayfile-cli/commandspec_test.go @@ -198,7 +198,7 @@ func TestOptionsMatchSourceFlagSets(t *testing.T) { walkSpec(publicCommandSpec(), nil, func(path []string, command cliCommandSpec) { label := strings.Join(path, " ") if command.flagSource == "" { - if len(command.Options) > 0 && !isPassThroughCommand(path) { + if len(command.Options) > 0 { t.Errorf("command %q declares options but names no flagSource", label) } return @@ -235,15 +235,42 @@ func TestOptionsMatchSourceFlagSets(t *testing.T) { }) } -func isMountCommand(path []string) bool { - return len(path) == 1 && path[0] == "mount" +// TestDeclaredArgsCoverSourcePositionals parses each command's flag.FlagSet +// out of the source and asserts that a command whose parser reads a positional +// value declares at least one positional argument. +// +// TestOptionsMatchSourceFlagSets covers flags only, so a command could read +// fs.Arg(0) while declaring no args — and `listen`, `dev` and +// `workspace status` all did. The binary accepts those invocations (run() +// forwards argv untouched), but a host that routes from the emitted spec — +// `agent-relay file` builds its parser from it — rejects them before the +// binary is ever reached, which is how the drift stayed invisible to every +// relayfile-side test. +// +// Only this direction is checked. fs.NArg() is deliberately not treated as a +// read: several commands call it solely to reject positionals. And a command +// may legitimately declare args it consumes before parsing (runStop and +// friends read args[0] directly), so "declares but no fs.Arg" is not drift. +func TestDeclaredArgsCoverSourcePositionals(t *testing.T) { + sources := parseCommandSources(t) + + walkSpec(publicCommandSpec(), nil, func(path []string, command cliCommandSpec) { + if command.flagSource == "" { + return + } + label := strings.Join(path, " ") + reads, ok := sources.readsPositional(command.flagSource) + if !ok { + t.Fatalf("command %q: flagSource %q not found in cmd/relayfile-cli", label, command.flagSource) + } + if reads && len(command.Args) == 0 { + t.Errorf("%s reads a positional argument but command %q declares none", command.flagSource, label) + } + }) } -// isPassThroughCommand reports whether a command forwards its argv to another -// command rather than parsing flags itself, so its declared options describe -// what the downstream command accepts. -func isPassThroughCommand(path []string) bool { - return strings.Join(path, " ") == "supervisor install" +func isMountCommand(path []string) bool { + return len(path) == 1 && path[0] == "mount" } func longFlagName(flags string) string { @@ -377,6 +404,35 @@ func (s *commandSources) flagNames(function string) ([]string, bool) { return names, true } +// readsPositional reports whether the named function reads a positional +// argument off its FlagSet: fs.Arg(i) or fs.Args(). +func (s *commandSources) readsPositional(function string) (bool, bool) { + fn, ok := s.functions[function] + if !ok { + return false, false + } + reads := false + ast.Inspect(fn.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + receiver, ok := selector.X.(*ast.Ident) + if !ok || !isFlagSetReceiver(receiver.Name) { + return true + } + if selector.Sel.Name == "Arg" || selector.Sel.Name == "Args" { + reads = true + } + return true + }) + return reads, true +} + // isFlagSetReceiver reports whether an identifier is one of the FlagSet // variables the CLI uses. `peek` is runDev's throwaway pre-parse set, which // registers a subset of runListen's flags; excluding it keeps runDev's diff --git a/cmd/relayfile-cli/listen_test.go b/cmd/relayfile-cli/listen_test.go index 63462d50..886a2338 100644 --- a/cmd/relayfile-cli/listen_test.go +++ b/cmd/relayfile-cli/listen_test.go @@ -1,6 +1,8 @@ package main import ( + "io" + "os" "strconv" "strings" "testing" @@ -189,3 +191,113 @@ func TestListenRunDuplicateKeyRequiresStableIdentity(t *testing.T) { t.Fatalf("expected content hash in duplicate key, got %q", key) } } + +// captureStderr runs fn with os.Stderr redirected, and returns what it wrote. +// +// The workspace-resolution warning below goes straight to os.Stderr rather +// than through the io.Writer the command is handed, so there is no other seam +// to read it from. +func captureStderr(t *testing.T, fn func() error) (string, error) { + t.Helper() + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("open pipe: %v", err) + } + original := os.Stderr + os.Stderr = writer + fnErr := fn() + os.Stderr = original + if err := writer.Close(); err != nil { + t.Fatalf("close pipe: %v", err) + } + captured, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read pipe: %v", err) + } + if err := reader.Close(); err != nil { + t.Fatalf("close pipe reader: %v", err) + } + return string(captured), fnErr +} + +// TestListenReadsTheWorkspaceFromItsFirstPositional pins the behaviour the +// command table has to declare. runListen takes the workspace as a positional +// (`relayfile listen WORKSPACE`), but the table declared no args for `listen` +// or for `dev`, which forwards its argv here — so `agent-relay file`, which +// builds its parser from the emitted spec, refused a workspace-qualified +// invocation before the binary ever saw it. +// +// HOME is empty, so the run stops at the local credential lookup and touches +// no network. What it names on the way there is the proof. +func TestListenReadsTheWorkspaceFromItsFirstPositional(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + const workspace = "listen-positional-probe" + withPositional, err := captureStderr(t, func() error { + return runListen([]string{workspace}, io.Discard) + }) + if err == nil { + t.Fatal("expected a credential-resolution error with an empty HOME") + } + if !strings.Contains(withPositional, strconv.Quote(workspace)) { + t.Errorf("listen %s resolved no workspace; stderr = %q", workspace, withPositional) + } + + withoutPositional, err := captureStderr(t, func() error { + return runListen(nil, io.Discard) + }) + if err == nil { + t.Fatal("expected a credential-resolution error with an empty HOME") + } + if strings.Contains(withoutPositional, strconv.Quote(workspace)) { + t.Errorf("bare listen named a workspace it was never given; stderr = %q", withoutPositional) + } +} + +// TestListenRejectsAFlagItDoesNotRegister is why the command table may not +// advertise a listen flag that runListen has never parsed: `supervisor +// install` embeds its argv into the unit's ExecStart as `relayfile listen +// ...`, under Restart=on-failure. A flag like --interval — which the table +// did advertise — makes that unit exit on every single start, forever. +func TestListenRejectsAFlagItDoesNotRegister(t *testing.T) { + err := runListen([]string{"--interval", "30s", "-h"}, io.Discard) + if err == nil || !strings.Contains(err.Error(), "flag provided but not defined: -interval") { + t.Fatalf("runListen --interval error = %v, want an undefined-flag error", err) + } +} + +// TestSupervisorInstallFlagsReachAParsingListener closes that loop from the +// other side: every flag the table declares for `supervisor install` must be +// one runListen accepts. +// +// The trailing -h aborts the parse as soon as the flag before it is accepted, +// so this exercises argument parsing only: no network, and no background +// process for --background. +func TestSupervisorInstallFlagsReachAParsingListener(t *testing.T) { + var install *cliCommandSpec + walkSpec(publicCommandSpec(), nil, func(path []string, command cliCommandSpec) { + if strings.Join(path, " ") == "supervisor install" { + declared := command + install = &declared + } + }) + if install == nil { + t.Fatal("no `supervisor install` in the command table") + } + if len(install.Options) == 0 { + t.Fatal("`supervisor install` declares no options; it forwards listen's") + } + + for _, option := range install.Options { + name := longFlagName(option.Flags) + if name == "" { + t.Errorf("option %q has no long flag", option.Flags) + continue + } + err := runListen([]string{"--" + name, "probe", "-h"}, io.Discard) + if err != nil && strings.Contains(err.Error(), "flag provided but not defined") { + t.Errorf("supervisor install declares --%s, but the listener it installs rejects it: %v", name, err) + } + } +} diff --git a/packages/cli/scripts/run.js b/packages/cli/scripts/run.js index e6b0e9e3..aa22dedb 100755 --- a/packages/cli/scripts/run.js +++ b/packages/cli/scripts/run.js @@ -66,23 +66,34 @@ async function main() { throw error; } + let command = resolution.command; + let childArgs = [...resolution.args, ...args]; + if (resolution.kind === "go-run") { + // `go run` would hand relayfile the checkout as its working directory, + // so relative paths in argv would resolve against the repository rather + // than the directory the user ran in. Build first, run from here. + try { + command = relayCli.buildGoRunBinary(resolution, { env: process.env }); + childArgs = [...args]; + } catch (error) { + if ( + error instanceof relayCli.GoToolchainMissingError || + error instanceof relayCli.GoBuildFailedError + ) { + console.error(error.message); + process.exit(1); + } + throw error; + } + } + // stdio is inherited rather than piped: this shim is the terminal-facing // entry point, so relayfile's own output (including binary payloads from - // `export --output -`) must pass through untouched. - const result = spawnSync( - resolution.command, - [...resolution.args, ...args], - { - cwd: resolution.kind === "go-run" ? resolution.cwd : undefined, - stdio: "inherit", - } - ); + // `export --output -`) must pass through untouched. No cwd override: the + // child inherits the caller's, which is what relative paths must mean. + const result = spawnSync(command, childArgs, { stdio: "inherit" }); if (result.error) { - if (result.error.code === "ENOENT" && resolution.kind === "go-run") { - console.error(relayCli.GO_TOOLCHAIN_MISSING_MESSAGE); - process.exit(1); - } console.error(`Failed to launch relayfile: ${result.error.message}`); process.exit(1); } diff --git a/packages/cli/scripts/run.test.js b/packages/cli/scripts/run.test.js index 10b4b350..fcfe2316 100644 --- a/packages/cli/scripts/run.test.js +++ b/packages/cli/scripts/run.test.js @@ -36,6 +36,17 @@ test("the shim owns no copy of binary resolution or the Cloud preflight", () => assert.doesNotMatch(source, /ensureCloudSession\(/); }); +test("the shim never relocates the child's working directory", () => { + // The source fallback used to spawn `go run` with the checkout as cwd, + // because that is the only directory `go` finds the module from — so a + // relative path in argv (`--output report.json`) resolved against the + // repository instead of wherever the user ran. The shim must materialize a + // binary through the SDK and spawn it with the caller's own cwd inherited. + const source = fs.readFileSync(shimPath, "utf8"); + assert.match(source, /buildGoRunBinary/); + assert.doesNotMatch(source, /cwd:/); +}); + test("the removed preflight module is not reintroduced", () => { assert.equal(fs.existsSync(path.join(__dirname, "cloud-preflight.js")), false); }); diff --git a/packages/sdk/typescript/src/relay-cli/command-spec.json b/packages/sdk/typescript/src/relay-cli/command-spec.json index b2baa5d8..8a969112 100644 --- a/packages/sdk/typescript/src/relay-cli/command-spec.json +++ b/packages/sdk/typescript/src/relay-cli/command-spec.json @@ -283,6 +283,13 @@ { "name": "status", "description": "Show sync status for a workspace", + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + } + ], "options": [ { "flags": "--workspace ", @@ -1277,8 +1284,43 @@ ], "options": [ { - "flags": "--interval ", - "description": "sync interval passed through to the supervised listen process" + "flags": "--server ", + "description": "relayfile server URL override" + }, + { + "flags": "--token ", + "description": "relayfile token override" + }, + { + "flags": "--provider ", + "description": "filter to a specific provider (e.g. linear, notion)" + }, + { + "flags": "--path ", + "description": "glob path filter (e.g. /linear/issues/**)" + }, + { + "flags": "--event ", + "description": "event type filter: file.created, file.updated, file.deleted" + }, + { + "flags": "--run ", + "description": "shell command per event; supports {{path}}, {{type}}, {{provider}}, {{revision}}, {{event}}" + }, + { + "flags": "--format ", + "description": "output format when --run is not set: text or json", + "defaultValue": "text" + }, + { + "flags": "--background", + "description": "run in background; logs to ~/.relayfile/listen.log", + "defaultValue": false + }, + { + "flags": "--daemonized", + "description": "internal flag used by relayfile listen --background", + "defaultValue": false } ] }, @@ -1529,6 +1571,13 @@ "aliases": [ "watch" ], + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + } + ], "options": [ { "flags": "--server ", @@ -1590,6 +1639,13 @@ { "name": "dev", "description": "Print workspace context, then stream file events like `listen`", + "args": [ + { + "name": "workspace", + "description": "workspace name or id; defaults to the active workspace", + "required": false + } + ], "options": [ { "flags": "--server ", diff --git a/packages/sdk/typescript/src/relay-cli/command-spec.test.ts b/packages/sdk/typescript/src/relay-cli/command-spec.test.ts index f7e58f57..bbdbe459 100644 --- a/packages/sdk/typescript/src/relay-cli/command-spec.test.ts +++ b/packages/sdk/typescript/src/relay-cli/command-spec.test.ts @@ -69,6 +69,36 @@ describe("command-spec.json", () => { expect(offenders).toEqual([]) }) + it("declares the optional workspace positional `listen` and `dev` accept", () => { + // The host builds its parser from this tree, so an omitted positional is + // a rejected invocation, not a documentation gap: `agent-relay file listen + // my-workspace` was refused before the binary saw it, even though + // `relayfile listen my-workspace` has always worked. + for (const name of ["listen", "dev"]) { + const command = relayfileCommands().find((candidate) => candidate.name === name) + expect(command, `\`${name}\` is missing from the command tree`).toBeDefined() + expect(command?.args?.[0]?.name, `\`${name}\` declares no workspace`).toBe("workspace") + expect(command?.args?.[0]?.required).toBe(false) + } + }) + + it("advertises nothing under `supervisor install` that `listen` cannot parse", () => { + // `supervisor install` embeds its argv into the service unit verbatim as + // `relayfile listen ...`, under Restart=on-failure. A flag here that + // listen does not accept installs a service that exits on every start. + const flagsOf = (commandPath: readonly string[]): string[] => { + for (const { path: candidate, command } of walkCommands(relayfileCommands())) { + if (candidate.join(" ") === commandPath.join(" ")) { + return (command.options ?? []).map((option) => option.flags) + } + } + throw new Error(`no \`${commandPath.join(" ")}\` in the command tree`) + } + + const listen = flagsOf(["listen"]) + expect(flagsOf(["supervisor", "install"]).filter((flag) => !listen.includes(flag))).toEqual([]) + }) + it("keeps the introspection hook out of the published tree", () => { const names = new Set() for (const { command } of walkCommands(relayfileCommands())) { diff --git a/packages/sdk/typescript/src/relay-cli/go-run-cwd.test.ts b/packages/sdk/typescript/src/relay-cli/go-run-cwd.test.ts new file mode 100644 index 00000000..b241bdf0 --- /dev/null +++ b/packages/sdk/typescript/src/relay-cli/go-run-cwd.test.ts @@ -0,0 +1,187 @@ +import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs" +import path from "node:path" + +import { beforeAll, describe, expect, it } from "vitest" + +import { + buildGoRunBinary, + createRelayCliSurface, + GoToolchainMissingError, + goRunBinaryPath, + resolveRelayfileBinary, + type RelayfileGoRunResolution +} from "./index.js" +import { checkoutRoot, temporaryDirectory } from "./testing/build-binary.js" + +/** + * The source fallback must not move the caller. + * + * When no binary is installed the resolver falls back to running relayfile + * from a Go source checkout. `go run` cannot express that: the program it + * launches inherits the `go` command's working directory, and `go` only finds + * the module from the checkout — so `relayfile read x --output out.json` run + * from anywhere else wrote `out.json` into the repository. (`go -C + * run` behaves the same, and an absolute package path fails outside a module, + * so building first is the only way to separate the two directories.) + * + * These tests pin the caller's directory, not the mechanism: a fixture + * checkout that prints its own working directory, launched through the real + * surface with a real spawn. + */ + +let fixtureRoot: string + +/** + * A minimal source checkout: `go.mod` plus a `cmd/relayfile-cli` that reports + * the working directory it was launched in. + * + * @returns The checkout root. + */ +function createFixtureCheckout(): string { + const root = realpathSync(temporaryDirectory("go-run-checkout")) + mkdirSync(path.join(root, "cmd", "relayfile-cli"), { recursive: true }) + writeFileSync( + path.join(root, "go.mod"), + "module example.com/relayfile-go-run-fixture\n\ngo 1.21\n" + ) + writeFileSync( + path.join(root, "cmd", "relayfile-cli", "main.go"), + `package main + +import ( + "fmt" + "os" +) + +func main() { + dir, err := os.Getwd() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Printf("cwd=%s\\n", dir) + fmt.Printf("args=%v\\n", os.Args[1:]) +} +` + ) + return root +} + +/** + * Resolution options that see the fixture checkout and nothing else. + * + * `resolveRelayfileBinary` always searches from this module's directory and + * the process cwd, both inside the real relayfile checkout, which would + * shadow the fixture with whatever the developer has built. Hiding the real + * checkout from `fileExists` leaves exactly one candidate. + * + * @returns Options for `resolveRelayfileBinary`. + */ +function fixtureResolveOptions(): { + binDirs: string[] + resolveFrom: string[] + pathEntries: string[] + searchFrom: string[] + env: NodeJS.ProcessEnv + fileExists: (candidate: string) => boolean +} { + const realRoot = realpathSync(checkoutRoot()) + return { + binDirs: [], + resolveFrom: [], + pathEntries: [], + searchFrom: [fixtureRoot], + env: {}, + fileExists: (candidate: string) => { + const resolved = path.resolve(candidate) + if (resolved === realRoot || resolved.startsWith(realRoot + path.sep)) { + return false + } + return existsSync(resolved) + } + } +} + +async function runFromSource( + argv: readonly string[], + cwd?: string +): Promise<{ code: number; stdout: string; stderr: string }> { + let stdout = "" + let stderr = "" + const code = await createRelayCliSurface({ + resolve: fixtureResolveOptions(), + env: process.env, + cwd, + skipCloudPreflight: true + }).run(argv, { + stdout: (chunk) => { + stdout += Buffer.from(chunk as Uint8Array).toString("utf8") + }, + stderr: (chunk) => { + stderr += Buffer.from(chunk as Uint8Array).toString("utf8") + } + }) + return { code, stdout, stderr } +} + +beforeAll(() => { + fixtureRoot = createFixtureCheckout() +}, 180_000) + +describe.skipIf(process.platform === "win32")("source fallback honours the caller's cwd", () => { + it("resolves the fixture checkout as a go-run fallback", () => { + const resolution = resolveRelayfileBinary(fixtureResolveOptions()) + expect(resolution.kind).toBe("go-run") + expect((resolution as RelayfileGoRunResolution).cwd).toBe(fixtureRoot) + }) + + it("runs in the requested directory, not the checkout", async () => { + const callerDir = realpathSync(temporaryDirectory("go-run-caller")) + const result = await runFromSource(["read", "/x", "--output", "out.json"], callerDir) + + expect(result.stderr).toBe("") + expect(result.code).toBe(0) + // The bug: this was the checkout root, so `--output out.json` landed there. + expect(result.stdout).toContain(`cwd=${callerDir}\n`) + expect(result.stdout).not.toContain(`cwd=${fixtureRoot}\n`) + }, 180_000) + + it("forwards argv unchanged, with no `go run` prefix left in it", async () => { + const callerDir = realpathSync(temporaryDirectory("go-run-argv")) + const result = await runFromSource(["tree", "/", "--depth", "2"], callerDir) + + expect(result.code).toBe(0) + expect(result.stdout).toContain("args=[tree / --depth 2]\n") + }, 180_000) + + it("falls back to the process cwd when the host names none", async () => { + const result = await runFromSource(["status"]) + + expect(result.code).toBe(0) + expect(result.stdout).toContain(`cwd=${realpathSync(process.cwd())}\n`) + }, 180_000) + + it("builds outside the checkout so a working tree is never written to", () => { + const resolution = resolveRelayfileBinary( + fixtureResolveOptions() + ) as RelayfileGoRunResolution + const built = buildGoRunBinary(resolution, { env: process.env }) + + expect(built).toBe(goRunBinaryPath(fixtureRoot)) + expect(existsSync(built)).toBe(true) + expect(path.resolve(built).startsWith(fixtureRoot + path.sep)).toBe(false) + }, 180_000) + + it("reports a missing Go toolchain instead of throwing at the host", async () => { + const resolution = resolveRelayfileBinary( + fixtureResolveOptions() + ) as RelayfileGoRunResolution + + expect(() => + buildGoRunBinary(resolution, { + env: { PATH: temporaryDirectory("go-run-empty-path") }, + outputPath: path.join(temporaryDirectory("go-run-missing"), "relayfile-cli") + }) + ).toThrow(GoToolchainMissingError) + }, 60_000) +}) diff --git a/packages/sdk/typescript/src/relay-cli/index.ts b/packages/sdk/typescript/src/relay-cli/index.ts index 7431b318..bb63adac 100644 --- a/packages/sdk/typescript/src/relay-cli/index.ts +++ b/packages/sdk/typescript/src/relay-cli/index.ts @@ -25,7 +25,9 @@ import { type EnsureCloudSession } from "./cloud-preflight.js" import { - GO_TOOLCHAIN_MISSING_MESSAGE, + buildGoRunBinary, + GoBuildFailedError, + GoToolchainMissingError, RelayfileBinaryNotFoundError, resolveRelayfileBinary, type RelayfileBinaryResolution, @@ -235,9 +237,29 @@ export function createRelayCliSurface( io.stderr(`${error.message}\n`) return RELAY_CLI_EXIT_BINARY_NOT_FOUND } - const command = resolution.command - const childArgs = [...resolution.args, ...args] - const cwd = resolution.kind === "go-run" ? resolution.cwd : options.cwd + let command = resolution.command + let childArgs = [...resolution.args, ...args] + if (resolution.kind === "go-run") { + // `go run` would launch relayfile with the checkout as its working + // directory — `go` only finds the module from there — so every + // relative path in argv would resolve against the repository instead + // of wherever the caller ran. Build first, then spawn the result from + // the caller's directory. + try { + command = buildGoRunBinary(resolution, { env }) + childArgs = [...args] + } catch (error) { + if ( + error instanceof GoToolchainMissingError || + error instanceof GoBuildFailedError + ) { + io.stderr(`${error.message}\n`) + return 1 + } + throw error + } + } + const cwd = options.cwd return await new Promise((resolve, reject) => { // stdin is inherited so interactive prompts (setup, login, delete @@ -255,12 +277,9 @@ export function createRelayCliSurface( child.stdout?.on("data", (chunk: Buffer) => io.stdout(chunk)) child.stderr?.on("data", (chunk: Buffer) => io.stderr(chunk)) + // A missing Go toolchain is reported by buildGoRunBinary above, not + // here: by this point `command` is always a real binary. child.on("error", (error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT" && resolution.kind === "go-run") { - io.stderr(`${GO_TOOLCHAIN_MISSING_MESSAGE}\n`) - resolve(1) - return - } reject(error) }) @@ -281,6 +300,10 @@ export function createRelayCliSurface( } export { + buildGoRunBinary, + goRunBinaryPath, + GoBuildFailedError, + GoToolchainMissingError, RelayfileBinaryNotFoundError, resolveRelayfileBinary, findSourceCheckoutRoot, @@ -292,7 +315,9 @@ export { platformPackageNames, GO_TOOLCHAIN_MISSING_MESSAGE, RELAYFILE_CLI_BIN_ENV, + type BuildGoRunBinaryOptions, type RelayfileBinaryResolution, + type RelayfileGoRunResolution, type ResolveRelayfileBinaryOptions } from "./resolve-binary.js" diff --git a/packages/sdk/typescript/src/relay-cli/resolve-binary.ts b/packages/sdk/typescript/src/relay-cli/resolve-binary.ts index 4325e07b..6c4cae6d 100644 --- a/packages/sdk/typescript/src/relay-cli/resolve-binary.ts +++ b/packages/sdk/typescript/src/relay-cli/resolve-binary.ts @@ -21,8 +21,10 @@ * per-platform build from GitHub Releases. */ +import { spawnSync } from "node:child_process" +import { createHash } from "node:crypto" +import { existsSync, mkdirSync } from "node:fs" import { createRequire } from "node:module" -import { existsSync } from "node:fs" import os from "node:os" import path from "node:path" import { fileURLToPath } from "node:url" @@ -521,3 +523,109 @@ export function resolveRelayfileBinary( export const GO_TOOLCHAIN_MISSING_MESSAGE = "relayfile binary not found and Go is not installed to run from source. " + "Install Go or run `npm run build --workspace=packages/cli`." + +/** A `go-run` resolution: no binary was found, but a checkout was. */ +export type RelayfileGoRunResolution = Extract< + RelayfileBinaryResolution, + { kind: "go-run" } +> + +/** Thrown by `buildGoRunBinary` when there is no `go` on PATH. */ +export class GoToolchainMissingError extends Error { + constructor() { + super(GO_TOOLCHAIN_MISSING_MESSAGE) + this.name = "GoToolchainMissingError" + } +} + +/** Thrown by `buildGoRunBinary` when `go build` itself fails. */ +export class GoBuildFailedError extends Error { + /** `go build`'s exit code, or null when it was killed by a signal. */ + readonly exitCode: number | null + + constructor(exitCode: number | null, stderr: string) { + super( + `building relayfile from source failed (go build exited ${exitCode})` + + (stderr.trim() ? `\n${stderr.trim()}` : "") + ) + this.name = "GoBuildFailedError" + this.exitCode = exitCode + } +} + +export interface BuildGoRunBinaryOptions { + /** Environment for `go build`; also supplies the PATH it is found on. */ + env?: NodeJS.ProcessEnv + /** Output path override, for tests. */ + outputPath?: string +} + +/** + * Where a source-checkout build is cached: outside the checkout, keyed by it. + * + * Not `/bin`, which is `make build`'s output — a fallback launch + * must not write into someone's working tree, and a read-only checkout still + * has to work. + * + * @param checkout - The source checkout root. + * @param platform - Node platform id; defaults to this host's. + * @returns The absolute path to build to. + */ +export function goRunBinaryPath( + checkout: string, + platform: string = os.platform() +): string { + const key = createHash("sha256").update(path.resolve(checkout)).digest("hex").slice(0, 16) + return path.join( + os.tmpdir(), + "relayfile-go-run", + key, + platformPackageBinaryName(platform) + ) +} + +/** + * Turn a `go-run` resolution into an executable binary. + * + * `go run` cannot be used directly, because the program it launches inherits + * the `go` command's own working directory — and `go` only finds the module + * from that directory, so it has to be the checkout. Every relative path in + * the caller's argv would then resolve against the repository instead of the + * directory the caller actually ran in (`--output report.json` writing into + * the checkout root). `go -C run` has the same effect, and passing + * an absolute package path fails outright outside a module. + * + * Building first and spawning the result separates the two: the build runs in + * the checkout, where the module is, and the binary runs wherever the caller + * asked for. Go's build cache makes the repeat cost a relink. + * + * @param resolution - The `go-run` resolution to materialize. + * @param options - Environment and output overrides. + * @returns The absolute path of the built binary. + * @throws {GoToolchainMissingError} When `go` is not on PATH. + * @throws {GoBuildFailedError} When `go build` exits non-zero. + */ +export function buildGoRunBinary( + resolution: RelayfileGoRunResolution, + options: BuildGoRunBinaryOptions = {} +): string { + const output = options.outputPath ?? goRunBinaryPath(resolution.cwd) + mkdirSync(path.dirname(output), { recursive: true }) + + const result = spawnSync("go", ["build", "-o", output, "./cmd/relayfile-cli"], { + cwd: resolution.cwd, + env: options.env ?? process.env, + encoding: "utf8" + }) + + if (result.error) { + if ((result.error as NodeJS.ErrnoException).code === "ENOENT") { + throw new GoToolchainMissingError() + } + throw result.error + } + if (result.status !== 0) { + throw new GoBuildFailedError(result.status, result.stderr ?? "") + } + return output +} From c0c7c9f92e1e4b7293e1315630e0bcee59f4fbc5 Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Thu, 17 Sep 2026 23:29:40 -0700 Subject: [PATCH 10/14] chore(trail): record PR #507 round-two findings trajectory Co-Authored-By: Claude Opus 5 (1M context) Session-Id: 6a85a22c-13e0-4844-a610-fabfeb3fc126 --- .../2026-09}/traj_3lyio30tipf8.json | 31 +- .../completed/2026-09/traj_3lyio30tipf8.md | 54 ++++ .../2026-09/traj_3lyio30tipf8.trace.json | 287 ++++++++++++++++++ .trajectories/index.json | 7 +- 4 files changed, 372 insertions(+), 7 deletions(-) rename .trajectories/{active => completed/2026-09}/traj_3lyio30tipf8.json (73%) create mode 100644 .trajectories/completed/2026-09/traj_3lyio30tipf8.md create mode 100644 .trajectories/completed/2026-09/traj_3lyio30tipf8.trace.json diff --git a/.trajectories/active/traj_3lyio30tipf8.json b/.trajectories/completed/2026-09/traj_3lyio30tipf8.json similarity index 73% rename from .trajectories/active/traj_3lyio30tipf8.json rename to .trajectories/completed/2026-09/traj_3lyio30tipf8.json index bc76a5b4..9967631f 100644 --- a/.trajectories/active/traj_3lyio30tipf8.json +++ b/.trajectories/completed/2026-09/traj_3lyio30tipf8.json @@ -8,8 +8,9 @@ "id": "PR-507" } }, - "status": "active", + "status": "completed", "startedAt": "2026-09-18T06:29:04.757Z", + "completedAt": "2026-09-18T06:29:37.710Z", "agents": [ { "name": "default", @@ -23,6 +24,7 @@ "title": "Work", "agentName": "default", "startedAt": "2026-09-18T06:29:15.251Z", + "endedAt": "2026-09-18T06:29:37.710Z", "events": [ { "ts": 1789712955251, @@ -75,12 +77,33 @@ ] } ], - "commits": [], - "filesChanged": [], + "retrospective": { + "summary": "Fixed 3 of 4 Devin findings on PR #507 and rejected the 4th as already fixed on-branch. go-run now builds then executes so the child keeps the caller's cwd (shared by the mounted surface and the bin shim); listen/dev/workspace-status declare their workspace positional; supervisor install declares runListen's flags instead of a --interval runListen rejects. Closed both AST drift blind spots. Tests: vitest relay-cli 105 pass/0 fail (was 97), SDK-wide 416 pass/1 pre-existing fail (client.test.ts ErrorEvent, fails on the unmodified branch too); go ./cmd/relayfile-cli 386 pass/0 fail/8 skip (was 382); packages/cli 15 pass/0 fail (was 14).", + "approach": "Standard approach", + "confidence": 0.85 + }, + "commits": [ + "c1751c42" + ], + "filesChanged": [ + ".trajectories/active/traj_3lyio30tipf8.json", + ".trajectories/index.json", + "cmd/relayfile-cli/commandspec.go", + "cmd/relayfile-cli/commandspec_test.go", + "cmd/relayfile-cli/listen_test.go", + "packages/cli/scripts/run.js", + "packages/cli/scripts/run.test.js", + "packages/sdk/typescript/src/relay-cli/command-spec.json", + "packages/sdk/typescript/src/relay-cli/command-spec.test.ts", + "packages/sdk/typescript/src/relay-cli/go-run-cwd.test.ts", + "packages/sdk/typescript/src/relay-cli/index.ts", + "packages/sdk/typescript/src/relay-cli/resolve-binary.ts" + ], "projectId": "AgentWorkforce/relayfile", "tags": [], "_trace": { "startRef": "f89e95460932c136a3597a90d91a0994dbd9242c", - "endRef": "f89e95460932c136a3597a90d91a0994dbd9242c" + "endRef": "c1751c420e45219ce6267237b4afcc12ccde46e3", + "traceId": "6b84ebad-ca78-4851-8b0e-30fa7719192d" } } \ No newline at end of file diff --git a/.trajectories/completed/2026-09/traj_3lyio30tipf8.md b/.trajectories/completed/2026-09/traj_3lyio30tipf8.md new file mode 100644 index 00000000..c12a9d20 --- /dev/null +++ b/.trajectories/completed/2026-09/traj_3lyio30tipf8.md @@ -0,0 +1,54 @@ +# Trajectory: Fix four Devin findings on relayfile PR #507 (CLI surface) + +> **Status:** ✅ Completed +> **Task:** PR-507 +> **Confidence:** 85% +> **Started:** September 17, 2026 at 11:29 PM +> **Completed:** September 17, 2026 at 11:29 PM + +--- + +## Summary + +Fixed 3 of 4 Devin findings on PR #507 and rejected the 4th as already fixed on-branch. go-run now builds then executes so the child keeps the caller's cwd (shared by the mounted surface and the bin shim); listen/dev/workspace-status declare their workspace positional; supervisor install declares runListen's flags instead of a --interval runListen rejects. Closed both AST drift blind spots. Tests: vitest relay-cli 105 pass/0 fail (was 97), SDK-wide 416 pass/1 pre-existing fail (client.test.ts ErrorEvent, fails on the unmodified branch too); go ./cmd/relayfile-cli 386 pass/0 fail/8 skip (was 382); packages/cli 15 pass/0 fail (was 14). + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Fix the go-run cwd bug by building then executing, in the shared resolver +- **Chose:** Fix the go-run cwd bug by building then executing, in the shared resolver +- **Reasoning:** go run gives the launched program the go command's own working directory, and go only finds the module from the checkout. I verified both escape hatches fail: an absolute package path outside a module errors ('go.mod file not found'), and 'go -C run' hands the child that same dir. Building to a temp path (not the checkout's bin/, so a working tree is never written to) and spawning the binary with the caller's cwd is the only way to separate the two. Put it in resolve-binary.ts so the CLI shim and the mounted surface share one implementation. + +### Declare listen's workspace positional on listen, dev and workspace status +- **Chose:** Declare listen's workspace positional on listen, dev and workspace status +- **Reasoning:** runListen reads fs.Arg(0) as the workspace and dev forwards argv to it verbatim; the host builds its parser from the emitted spec, so an undeclared positional is a rejected-but-valid invocation. workspace status is the same bug, surfaced by the new AST guard rather than by the review. + +### Give supervisor install flagSource runListen instead of deleting --interval +- **Chose:** Give supervisor install flagSource runListen instead of deleting --interval +- **Reasoning:** The drift test exempted 'supervisor install' from the flag check (isPassThroughCommand) precisely because it declared options with no flagSource — that exemption is why --interval survived. Removing the exemption and pointing flagSource at runListen makes the table's claim checkable: supervisor install embeds its argv into ExecStart as 'relayfile listen ...', so its options are listen's options, no more and no less. Deleting --interval alone would have left it under-declaring the flags it really forwards. + +### Rejected finding 1 (binary stdout) as already fixed +- **Chose:** Rejected finding 1 (binary stdout) as already fixed +- **Reasoning:** setEncoding was removed in af70448f on this same branch, before the review round. I mutation-checked the covering test (binary-output.test.ts) by reintroducing setEncoding: it fails on the 0xff 0xfe payload, so the guard is real, not vacuous. No change made. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Fix the go-run cwd bug by building then executing, in the shared resolver: Fix the go-run cwd bug by building then executing, in the shared resolver +- Declare listen's workspace positional on listen, dev and workspace status: Declare listen's workspace positional on listen, dev and workspace status +- Give supervisor install flagSource runListen instead of deleting --interval: Give supervisor install flagSource runListen instead of deleting --interval +- Rejected finding 1 (binary stdout) as already fixed: Rejected finding 1 (binary stdout) as already fixed + +--- + +## Artifacts + +**Commits:** c1751c42 +**Files changed:** 12 diff --git a/.trajectories/completed/2026-09/traj_3lyio30tipf8.trace.json b/.trajectories/completed/2026-09/traj_3lyio30tipf8.trace.json new file mode 100644 index 00000000..96534811 --- /dev/null +++ b/.trajectories/completed/2026-09/traj_3lyio30tipf8.trace.json @@ -0,0 +1,287 @@ +{ + "version": "1.0.0", + "id": "6b84ebad-ca78-4851-8b0e-30fa7719192d", + "timestamp": "2026-09-18T06:29:37.731Z", + "trajectory": "traj_3lyio30tipf8", + "files": [ + { + "path": ".trajectories/active/traj_3lyio30tipf8.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 86, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + } + ] + } + ] + }, + { + "path": ".trajectories/index.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 6, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + }, + { + "start_line": 393, + "end_line": 404, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/commandspec.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 264, + "end_line": 270, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + }, + { + "start_line": 624, + "end_line": 638, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + }, + { + "start_line": 773, + "end_line": 779, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + }, + { + "start_line": 802, + "end_line": 810, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/commandspec_test.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 198, + "end_line": 204, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + }, + { + "start_line": 235, + "end_line": 276, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + }, + { + "start_line": 404, + "end_line": 438, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/listen_test.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 8, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + }, + { + "start_line": 191, + "end_line": 303, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + } + ] + } + ] + }, + { + "path": "packages/cli/scripts/run.js", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 66, + "end_line": 99, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + } + ] + } + ] + }, + { + "path": "packages/cli/scripts/run.test.js", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 36, + "end_line": 52, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/command-spec.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 283, + "end_line": 295, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + }, + { + "start_line": 1284, + "end_line": 1326, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + }, + { + "start_line": 1571, + "end_line": 1583, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + }, + { + "start_line": 1639, + "end_line": 1651, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/command-spec.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 69, + "end_line": 104, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/go-run-cwd.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 187, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/index.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 25, + "end_line": 33, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + }, + { + "start_line": 237, + "end_line": 265, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + }, + { + "start_line": 277, + "end_line": 285, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + }, + { + "start_line": 300, + "end_line": 309, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + }, + { + "start_line": 315, + "end_line": 323, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/resolve-binary.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 21, + "end_line": 30, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + }, + { + "start_line": 523, + "end_line": 631, + "revision": "c1751c420e45219ce6267237b4afcc12ccde46e3" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.trajectories/index.json b/.trajectories/index.json index 4d015916..4735bf66 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-09-18T06:29:20.150Z", + "lastUpdated": "2026-09-18T06:29:37.765Z", "trajectories": { "traj_4pvrlmqfnzng": { "title": "Review PR #278 in AgentWorkforce/relayfile", @@ -396,9 +396,10 @@ }, "traj_3lyio30tipf8": { "title": "Fix four Devin findings on relayfile PR #507 (CLI surface)", - "status": "active", + "status": "completed", "startedAt": "2026-09-18T06:29:04.757Z", - "path": "/home/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/active/traj_3lyio30tipf8.json" + "completedAt": "2026-09-18T06:29:37.710Z", + "path": "/home/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/completed/2026-09/traj_3lyio30tipf8.json" } } } \ No newline at end of file From 532ca88d88dafaea235200b3d92aff0b00a0f122 Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Fri, 18 Sep 2026 06:20:03 -0700 Subject: [PATCH 11/14] chore: lock the @relayfile/cli-* platform packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six platform packages are on the registry now, so npm can finally record them and `npm ci` resolves a complete tree. That was the last thing failing Release Tooling on this PR. The published 0.10.56 tarballs are empty — `bin/` holds only .gitkeep, no binary — so they satisfy resolution but cannot serve a run yet. The next release bumps past them and this workflow's 'Prepare cli platform package' step stages the cross-compiled binary and `test -f`s it first, so a release-built package cannot ship hollow the way a hand publish did. Co-Authored-By: Claude Opus 5 (1M context) Session-Id: d458bd97-53d8-4f02-be9c-48b67b93c916 --- package-lock.json | 80 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index c028ed31..56ab3fec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -895,7 +895,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -2148,6 +2148,84 @@ "resolved": "packages/agents", "link": true }, + "node_modules/@relayfile/cli-darwin-arm64": { + "version": "0.10.56", + "resolved": "https://registry.npmjs.org/@relayfile/cli-darwin-arm64/-/cli-darwin-arm64-0.10.56.tgz", + "integrity": "sha512-L8KR9FWYOScsfU0AXJ5UixrDMdp9VP1rBvwAbgP0gkLAAi+nRyV77YizqagONNUpceRnftr22bwIoofb7/sSRQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@relayfile/cli-darwin-x64": { + "version": "0.10.56", + "resolved": "https://registry.npmjs.org/@relayfile/cli-darwin-x64/-/cli-darwin-x64-0.10.56.tgz", + "integrity": "sha512-yFyNJge2Ss/2smUMsXWhFbD43Twekp3lniaa7pK1yKfq8k81DcwPkZkPE6eJeFfrs2HYPxpToV57YblZwHhdPA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@relayfile/cli-linux-arm64": { + "version": "0.10.56", + "resolved": "https://registry.npmjs.org/@relayfile/cli-linux-arm64/-/cli-linux-arm64-0.10.56.tgz", + "integrity": "sha512-1BSXTtB83Q+VmHmoHIlC7XJFfZx02pyRFbcjQQ9Z4B2VhjBIdRhfFgh7946EGQCR+Os85M8EN8bas6fHIkc8Lg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@relayfile/cli-linux-x64": { + "version": "0.10.56", + "resolved": "https://registry.npmjs.org/@relayfile/cli-linux-x64/-/cli-linux-x64-0.10.56.tgz", + "integrity": "sha512-jtWro/umm1JM81rLbRN+PAWCLvaFU821EwPsVqszkRLDpdCpgCBiL/XzICAcPBLNcIWgu0zhY+6cDKRVflclOg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@relayfile/cli-win32-arm64": { + "version": "0.10.56", + "resolved": "https://registry.npmjs.org/@relayfile/cli-win32-arm64/-/cli-win32-arm64-0.10.56.tgz", + "integrity": "sha512-XIFXIHCpZqcFLJgBQCvxJ5xbCnt9wru6YVuTcTDEMkqosKYB8os7TEGepJI/H/Yx6PIUcaT7treI5h4KwGb5NA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@relayfile/cli-win32-x64": { + "version": "0.10.56", + "resolved": "https://registry.npmjs.org/@relayfile/cli-win32-x64/-/cli-win32-x64-0.10.56.tgz", + "integrity": "sha512-NiGf9vmI614v8YOHS7RAl74UNaaelvuXs0CXKD68iMdf6Ts/UmBycJ2DggHXUJCF1r1f5aGU5xWIrBHUedOkfQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@relayfile/client": { "resolved": "packages/client", "link": true From 690ed387a5f8e374a714c9d8e95a1d5bdfc643ef Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Fri, 18 Sep 2026 06:36:17 -0700 Subject: [PATCH 12/14] fix(release): cover the @relayfile/cli-* packages in release trust and attestation The six platform packages were added to the workflow's PACKAGE_PATHS_JSON and to the release commit's `git add`, but not to the two constants the release trust chain is built from. resolve-release-baseline.mjs derives RELEASE_COMMIT_PATHS from RELEASE_PACKAGE_PATHS. A release commit that touches a package.json the resolver does not list fails the changed-path allowlist, so its annotated tag stops being a trusted baseline: the next dispatch falls back to the lagging source version, bumps onto the version that is already tagged, and aborts on the tag-collision guard. The resolver also never checked that the cli-* packages carried the tag version. create-release-attestation.mjs rejects any package record whose name is not in RELEASE_PACKAGE_NAMES, while the release job requires one record per published package (17). The cli-* packages are published and reconciled, so the release attestation step would have failed outright and the executable half of the release would otherwise ship unattested. Add a guard test tying RELEASE_PACKAGE_PATHS, RELEASE_PACKAGE_NAMES, and the workflow's attestation-count gate to the shared package list. Co-Authored-By: Claude Opus 5 (1M context) Session-Id: d458bd97-53d8-4f02-be9c-48b67b93c916 --- .../release/create-release-attestation.mjs | 6 +++++ scripts/release/publish-workflow.test.mjs | 25 +++++++++++++++++++ scripts/release/resolve-release-baseline.mjs | 6 +++++ 3 files changed, 37 insertions(+) diff --git a/scripts/release/create-release-attestation.mjs b/scripts/release/create-release-attestation.mjs index 72514f5b..06bd9e80 100644 --- a/scripts/release/create-release-attestation.mjs +++ b/scripts/release/create-release-attestation.mjs @@ -70,6 +70,12 @@ export const RELEASE_PACKAGE_NAMES = [ "@relayfile/mount-darwin-x64", "@relayfile/mount-linux-arm64", "@relayfile/mount-linux-x64", + "@relayfile/cli-darwin-arm64", + "@relayfile/cli-darwin-x64", + "@relayfile/cli-linux-arm64", + "@relayfile/cli-linux-x64", + "@relayfile/cli-win32-arm64", + "@relayfile/cli-win32-x64", ]; export const RELEASE_BINARY_NAMES = [ diff --git a/scripts/release/publish-workflow.test.mjs b/scripts/release/publish-workflow.test.mjs index bdc28809..ebc69f7f 100644 --- a/scripts/release/publish-workflow.test.mjs +++ b/scripts/release/publish-workflow.test.mjs @@ -24,6 +24,7 @@ import { RELEASE_BINARY_NAMES, RELEASE_PACKAGE_NAMES, } from "./create-release-attestation.mjs"; +import { RELEASE_PACKAGE_PATHS } from "./resolve-release-baseline.mjs"; const VALID_INTEGRITY = `sha512-${"A".repeat(86)}==`; const VALID_SHASUM = "a".repeat(40); @@ -73,6 +74,12 @@ const EXPECTED_PACKAGE_PATHS = [ "packages/mount-darwin-x64/package.json", "packages/mount-linux-arm64/package.json", "packages/mount-linux-x64/package.json", + "packages/cli-darwin-arm64/package.json", + "packages/cli-darwin-x64/package.json", + "packages/cli-linux-arm64/package.json", + "packages/cli-linux-x64/package.json", + "packages/cli-win32-arm64/package.json", + "packages/cli-win32-x64/package.json", ]; function dedent(block) { @@ -759,6 +766,24 @@ test("the shared package list still covers every published package", () => { assert.deepEqual(paths, EXPECTED_PACKAGE_PATHS); }); +test("the trust resolver and the attestation cover the release set the workflow ships", () => { + // A release commit that touches a package the resolver does not know about + // falls outside RELEASE_COMMIT_PATHS, so its tag stops being a trusted + // baseline and the next dispatch bumps back onto the already-tagged version. + assert.deepEqual(RELEASE_PACKAGE_PATHS, [ + "package.json", + ...EXPECTED_PACKAGE_PATHS, + ]); + // Every versioned package is published and reconciled, so the attestation + // must name exactly as many packages as the release job demands records for. + assert.equal(RELEASE_PACKAGE_NAMES.length, EXPECTED_PACKAGE_PATHS.length); + const gate = WORKFLOW.match( + /find package-attestations -type f -name '\*\.json' \| wc -l \| tr -d ' '\)" -eq (\d+)/, + ); + assert.ok(gate, "package attestation count gate not found"); + assert.equal(Number(gate[1]), RELEASE_PACKAGE_NAMES.length); +}); + test("the version-sync script consumes the shared list rather than its own copy", () => { assert.match(WORKFLOW, /const packagePaths = \$\{PACKAGE_PATHS_JSON\};/); const inlineArrays = diff --git a/scripts/release/resolve-release-baseline.mjs b/scripts/release/resolve-release-baseline.mjs index d221c7a6..b6f4ec48 100644 --- a/scripts/release/resolve-release-baseline.mjs +++ b/scripts/release/resolve-release-baseline.mjs @@ -48,6 +48,12 @@ export const RELEASE_PACKAGE_PATHS = [ "packages/mount-darwin-x64/package.json", "packages/mount-linux-arm64/package.json", "packages/mount-linux-x64/package.json", + "packages/cli-darwin-arm64/package.json", + "packages/cli-darwin-x64/package.json", + "packages/cli-linux-arm64/package.json", + "packages/cli-linux-x64/package.json", + "packages/cli-win32-arm64/package.json", + "packages/cli-win32-x64/package.json", ]; // The release workflow creates one commit from SOURCE_SHA after npm version, From 95bbfc3f3c7c07a263bdbaa4005ca8e05428f757 Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Fri, 18 Sep 2026 07:06:59 -0700 Subject: [PATCH 13/14] fix(cli-surface): keep detach flags out of supervisor units, stage go-run builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `supervisor install` copied all of listenOptions() into its spec, which swept in --background and --daemonized. Both parse, so the drift tests were happy, but runListen acts on them before it connects: --background re-execs a detached child and returns, leaving launchd's KeepAlive relaunching a process that exits every start and systemd killing the orphan with the unit's cgroup. listenOptions() now splits into filters and process-model flags; the unit gets the filters, and supervisorInstall refuses the rest so argv that reaches the binary directly is rejected too. buildGoRunBinary built straight to the shared path keyed by the checkout, which a `listen` or `mount` launched from it holds open for hours — impossible to replace on Windows, and a torn-read race between two builders on Unix. The build now writes a private sibling and publishes it with one rename, falls back to the staged path when the shared name cannot be replaced, and sweeps artifacts older than a day. Co-Authored-By: Claude Opus 5 (1M context) Session-Id: d458bd97-53d8-4f02-be9c-48b67b93c916 --- .trajectories/active/traj_p2o6b2q8epj1.json | 62 ++++++ .trajectories/index.json | 8 +- cmd/relayfile-cli/commandspec.go | 81 ++++++- cmd/relayfile-cli/commandspec_test.go | 31 ++- cmd/relayfile-cli/listen_test.go | 132 +++++++++++- cmd/relayfile-cli/main.go | 47 +++- .../src/relay-cli/command-spec.json | 10 - .../src/relay-cli/go-run-build-path.test.ts | 202 ++++++++++++++++++ .../src/relay-cli/resolve-binary.ts | 126 ++++++++++- 9 files changed, 662 insertions(+), 37 deletions(-) create mode 100644 .trajectories/active/traj_p2o6b2q8epj1.json create mode 100644 packages/sdk/typescript/src/relay-cli/go-run-build-path.test.ts diff --git a/.trajectories/active/traj_p2o6b2q8epj1.json b/.trajectories/active/traj_p2o6b2q8epj1.json new file mode 100644 index 00000000..cdd9daab --- /dev/null +++ b/.trajectories/active/traj_p2o6b2q8epj1.json @@ -0,0 +1,62 @@ +{ + "id": "traj_p2o6b2q8epj1", + "version": 1, + "task": { + "title": "PR #507 round-3 bugbot: supervisor install detach flags + go-run build path races", + "source": { + "system": "plain", + "id": "PR-507" + } + }, + "status": "active", + "startedAt": "2026-09-18T13:58:18.225Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-09-18T14:06:48.912Z" + } + ], + "chapters": [ + { + "id": "chap_s1h2txrpnjjy", + "title": "Work", + "agentName": "default", + "startedAt": "2026-09-18T14:06:48.912Z", + "events": [ + { + "ts": 1789740408912, + "type": "decision", + "content": "Split listenOptions into filters vs process-model; supervisor install advertises filters only and rejects the rest at runtime: Split listenOptions into filters vs process-model; supervisor install advertises filters only and rejects the rest at runtime", + "raw": { + "question": "Split listenOptions into filters vs process-model; supervisor install advertises filters only and rejects the rest at runtime", + "chosen": "Split listenOptions into filters vs process-model; supervisor install advertises filters only and rejects the rest at runtime", + "alternatives": [], + "reasoning": "runListen acts on --background/--daemonized before connecting, so they parse fine but break any unit that embeds them; the surface and the binary both have to say no" + }, + "significance": "high" + }, + { + "ts": 1789740409348, + "type": "decision", + "content": "buildGoRunBinary stages to a private sibling and publishes by rename, with a returned-fallback and an age-bounded sweep: buildGoRunBinary stages to a private sibling and publishes by rename, with a returned-fallback and an age-bounded sweep", + "raw": { + "question": "buildGoRunBinary stages to a private sibling and publishes by rename, with a returned-fallback and an age-bounded sweep", + "chosen": "buildGoRunBinary stages to a private sibling and publishes by rename, with a returned-fallback and an age-bounded sweep", + "alternatives": [], + "reasoning": "Shared path keyed by checkout alone is held open by long-running listen/mount; rename is atomic on POSIX and the fallback covers Windows, where replacing a running exe is impossible" + }, + "significance": "high" + } + ] + } + ], + "commits": [], + "filesChanged": [], + "projectId": "/home/khaliqgant/Projects/AgentWorkforce/relayfile", + "tags": [], + "_trace": { + "startRef": "690ed387a5f8e374a714c9d8e95a1d5bdfc643ef", + "endRef": "690ed387a5f8e374a714c9d8e95a1d5bdfc643ef" + } +} \ No newline at end of file diff --git a/.trajectories/index.json b/.trajectories/index.json index 4735bf66..42b59b10 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-09-18T06:29:37.765Z", + "lastUpdated": "2026-09-18T14:06:49.350Z", "trajectories": { "traj_4pvrlmqfnzng": { "title": "Review PR #278 in AgentWorkforce/relayfile", @@ -400,6 +400,12 @@ "startedAt": "2026-09-18T06:29:04.757Z", "completedAt": "2026-09-18T06:29:37.710Z", "path": "/home/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/completed/2026-09/traj_3lyio30tipf8.json" + }, + "traj_p2o6b2q8epj1": { + "title": "PR #507 round-3 bugbot: supervisor install detach flags + go-run build path races", + "status": "active", + "startedAt": "2026-09-18T13:58:18.225Z", + "path": "/home/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/active/traj_p2o6b2q8epj1.json" } } } \ No newline at end of file diff --git a/cmd/relayfile-cli/commandspec.go b/cmd/relayfile-cli/commandspec.go index 66decb71..8dd8ab3e 100644 --- a/cmd/relayfile-cli/commandspec.go +++ b/cmd/relayfile-cli/commandspec.go @@ -6,6 +6,7 @@ import ( "flag" "fmt" "io" + "regexp" "strings" ) @@ -75,6 +76,14 @@ type cliCommandSpec struct { // switch's cases exactly. dispatchSource string + // withheldFlags names flags that flagSource registers but this command + // deliberately does not advertise. The drift test otherwise requires the + // declared options to cover the flag set exactly; an entry here is the + // explicit, checked exception. The test asserts each name is really + // registered by flagSource and really undeclared, so the list cannot rot + // into a way of hiding genuine drift. + withheldFlags []string + // internal keeps a command out of the emitted spec. Reserved for // introspection hooks that the host provides itself or that are not part // of the product surface. @@ -630,9 +639,17 @@ func relayfileCommands() []cliCommandSpec { // a flag declared here that runListen does not register // installs a service that exits on every start and, under // Restart=on-failure, restarts forever. - flagSource: "runListen", - Args: []cliArgSpec{workspaceArg}, - Options: listenOptions(), + // + // The converse also holds, which is why this takes + // listen's filters rather than all of listenOptions(): + // runListen's process-model flags parse fine but make the + // supervised process detach or rotate the unit's own log, + // so they are withheld from the surface (and rejected at + // runtime by supervisorInstall). + flagSource: "runListen", + Args: []cliArgSpec{workspaceArg}, + Options: listenFilterOptions(), + withheldFlags: listenProcessModelFlagNames(), }, { Name: "uninstall", @@ -846,7 +863,12 @@ func writebackMutationOptions() []cliOptionSpec { } } -func listenOptions() []cliOptionSpec { +// listenFilterOptions are the `listen` flags that describe *what* to stream +// and where to stream it from. runListen turns every one of them into a +// filter, a credential, or an output format, and none of them changes how the +// process itself runs — so they are exactly the flags that can be embedded in +// a launchd/systemd unit's ExecStart. +func listenFilterOptions() []cliOptionSpec { return []cliOptionSpec{ serverFlagOption, tokenFlagOption, @@ -855,11 +877,49 @@ func listenOptions() []cliOptionSpec { {Flags: "--event ", Description: "event type filter: file.created, file.updated, file.deleted"}, {Flags: "--run ", Description: "shell command per event; supports {{path}}, {{type}}, {{provider}}, {{revision}}, {{event}}"}, {Flags: "--format ", Description: "output format when --run is not set: text or json", DefaultValue: "text"}, + } +} + +// listenProcessModelOptions are the `listen` flags that choose how the process +// runs rather than what it streams, and runListen acts on both before it opens +// a single connection: +// +// - --background re-execs a detached `listen --daemonized` child and +// returns, so the process systemd/launchd is supervising exits +// immediately. systemd then tears the orphaned grandchild down with the +// unit's cgroup, and launchd's KeepAlive=true relaunches the exiting +// parent forever. +// - --daemonized is the internal marker that detached child is spawned +// with. It also rotates ~/.relayfile/listen.log, which is the same file +// the installed unit appends its own stdout and stderr to. +// +// Neither may be advertised on `supervisor install`, whose argv goes verbatim +// into the unit. This is the same class of bug as advertising --interval +// there: a flag that makes the supervised process wrong on every start. +func listenProcessModelOptions() []cliOptionSpec { + return []cliOptionSpec{ {Flags: "--background", Description: "run in background; logs to ~/.relayfile/listen.log", DefaultValue: false}, {Flags: "--daemonized", Description: "internal flag used by relayfile listen --background", DefaultValue: false}, } } +// listenProcessModelFlagNames is listenProcessModelOptions as bare long flag +// names, for the places that match argv or withhold flags by name. +func listenProcessModelFlagNames() []string { + options := listenProcessModelOptions() + names := make([]string, 0, len(options)) + for _, option := range options { + if name := optionLongName(option.Flags); name != "" { + names = append(names, name) + } + } + return names +} + +func listenOptions() []cliOptionSpec { + return append(listenFilterOptions(), listenProcessModelOptions()...) +} + func mountOptions() []cliOptionSpec { return []cliOptionSpec{ {Flags: "--server ", Description: "relayfile server URL"}, @@ -977,6 +1037,19 @@ func lookupCommand(name string) (cliCommandSpec, bool) { return cliCommandSpec{}, false } +// optionLongName extracts the long flag name from a commander-style flag +// string: "--path " yields "path". It returns "" when the string +// declares no long flag. +func optionLongName(flags string) string { + match := optionLongNameRe.FindStringSubmatch(flags) + if match == nil { + return "" + } + return match[1] +} + +var optionLongNameRe = regexp.MustCompile(`--([A-Za-z0-9][A-Za-z0-9-]*)`) + // publicCommandSpec strips the internal bookkeeping and the commands that are // not part of the product surface, leaving exactly what the contract describes. func publicCommandSpec() []cliCommandSpec { diff --git a/cmd/relayfile-cli/commandspec_test.go b/cmd/relayfile-cli/commandspec_test.go index 7486be36..e6c21281 100644 --- a/cmd/relayfile-cli/commandspec_test.go +++ b/cmd/relayfile-cli/commandspec_test.go @@ -70,7 +70,7 @@ func TestCommandSpecNamesAndFlagsSatisfyContract(t *testing.T) { if !flagStringRe.MatchString(option.Flags) { t.Errorf("command %q: flags %q is not a commander flag string", label, option.Flags) } - long := longFlagName(option.Flags) + long := optionLongName(option.Flags) if long == "" { continue } @@ -192,6 +192,12 @@ func TestSubcommandsMatchSourceSwitches(t *testing.T) { // registers. Flags whose names are not kebab-case cannot be expressed by the // contract, so they are allowed to exist undeclared as long as a kebab-case // alias for them is declared (see --opId / --op-id). +// +// A command may also withhold a registered flag on purpose — `supervisor +// install` shares runListen's flag set but must not advertise the flags that +// detach the process it installs. Those are listed in withheldFlags and +// checked from both sides below, so the exception cannot become a hiding +// place for real drift. func TestOptionsMatchSourceFlagSets(t *testing.T) { sources := parseCommandSources(t) @@ -211,13 +217,24 @@ func TestOptionsMatchSourceFlagSets(t *testing.T) { declared := map[string]bool{} for _, option := range command.Options { - if long := longFlagName(option.Flags); long != "" { + if long := optionLongName(option.Flags); long != "" { declared[long] = true } } - for _, name := range registered { + withheld := map[string]bool{} + for _, name := range command.withheldFlags { + withheld[name] = true + if !contains(registered, name) { + t.Errorf("command %q withholds --%s but %s does not register it", label, name, command.flagSource) + } if declared[name] { + t.Errorf("command %q withholds --%s and declares it too", label, name) + } + } + + for _, name := range registered { + if declared[name] || withheld[name] { continue } if !commandNameRe.MatchString(name) { @@ -273,14 +290,6 @@ func isMountCommand(path []string) bool { return len(path) == 1 && path[0] == "mount" } -func longFlagName(flags string) string { - match := regexp.MustCompile(`--([A-Za-z0-9][A-Za-z0-9-]*)`).FindStringSubmatch(flags) - if match == nil { - return "" - } - return match[1] -} - func contains(values []string, want string) bool { for _, value := range values { if value == want { diff --git a/cmd/relayfile-cli/listen_test.go b/cmd/relayfile-cli/listen_test.go index 886a2338..07e2eb23 100644 --- a/cmd/relayfile-cli/listen_test.go +++ b/cmd/relayfile-cli/listen_test.go @@ -3,6 +3,7 @@ package main import ( "io" "os" + "path/filepath" "strconv" "strings" "testing" @@ -290,7 +291,7 @@ func TestSupervisorInstallFlagsReachAParsingListener(t *testing.T) { } for _, option := range install.Options { - name := longFlagName(option.Flags) + name := optionLongName(option.Flags) if name == "" { t.Errorf("option %q has no long flag", option.Flags) continue @@ -301,3 +302,132 @@ func TestSupervisorInstallFlagsReachAParsingListener(t *testing.T) { } } } + +// supervisorInstallSpec returns the `supervisor install` entry from the +// emitted public command tree — the tree `agent-relay file` builds its parser +// from, so it is the surface these assertions are about. +func supervisorInstallSpec(t *testing.T) cliCommandSpec { + t.Helper() + var install *cliCommandSpec + walkSpec(publicCommandSpec(), nil, func(path []string, command cliCommandSpec) { + if strings.Join(path, " ") == "supervisor install" { + declared := command + install = &declared + } + }) + if install == nil { + t.Fatal("no `supervisor install` in the command table") + } + return *install +} + +// TestSupervisorInstallAdvertisesFiltersNotProcessModelFlags is the other half +// of TestSupervisorInstallFlagsReachAParsingListener. That test only asks +// whether runListen *parses* a declared flag; --background parses fine and +// still breaks the service, because runListen re-execs a detached child and +// returns, leaving launchd's KeepAlive relaunching a process that exits every +// time and systemd killing the orphan with the unit's cgroup. +// +// So the surface must advertise listen's filters and withhold its +// process-model flags — while `listen` itself, which is not supervised, keeps +// advertising both. +func TestSupervisorInstallAdvertisesFiltersNotProcessModelFlags(t *testing.T) { + declared := map[string]bool{} + for _, option := range supervisorInstallSpec(t).Options { + if name := optionLongName(option.Flags); name != "" { + declared[name] = true + } + } + if len(declared) == 0 { + t.Fatal("`supervisor install` declares no options; it forwards listen's filters") + } + + for _, option := range listenProcessModelOptions() { + name := optionLongName(option.Flags) + if declared[name] { + t.Errorf("`supervisor install` advertises --%s; embedding it in ExecStart installs a service that detaches and exits on every start", name) + } + } + for _, option := range listenFilterOptions() { + name := optionLongName(option.Flags) + if !declared[name] { + t.Errorf("`supervisor install` no longer advertises the listen filter --%s", name) + } + } + + var listenDeclared map[string]bool + walkSpec(publicCommandSpec(), nil, func(path []string, command cliCommandSpec) { + if strings.Join(path, " ") != "listen" { + return + } + listenDeclared = map[string]bool{} + for _, option := range command.Options { + if name := optionLongName(option.Flags); name != "" { + listenDeclared[name] = true + } + } + }) + if listenDeclared == nil { + t.Fatal("no `listen` in the command table") + } + for _, option := range listenProcessModelOptions() { + name := optionLongName(option.Flags) + if !listenDeclared[name] { + t.Errorf("`listen` no longer advertises --%s; only the supervised copy withholds it", name) + } + } +} + +// TestSupervisorInstallRejectsProcessModelFlags covers the same rule at the +// binary's own boundary. The emitted spec stops `agent-relay file supervisor +// install --background`, but `relayfile supervisor install --background` run +// directly reaches supervisorInstall with that argv, and it used to write it +// straight into ExecStart. +// +// HOME is a temp dir, so a unit written despite the rejection is visible as a +// file. PATH is emptied as well: the guard returns before any supervisor +// process runs, and if it ever stops doing so this test must fail on the +// assertions below rather than enable a real service on the machine running +// it. +func TestSupervisorInstallRejectsProcessModelFlags(t *testing.T) { + for _, args := range [][]string{ + {"--background"}, + {"-background"}, + {"--daemonized=true"}, + {"--path", "/linear/**", "--background", "--run", "notify"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("PATH", t.TempDir()) + + err := supervisorInstall(args, io.Discard) + if err == nil { + t.Fatalf("supervisorInstall(%q) succeeded; it must refuse a process-model flag", args) + } + if !strings.Contains(err.Error(), "cannot embed") { + t.Fatalf("supervisorInstall(%q) error = %v, want a refusal naming the flag", args, err) + } + + unit := filepath.Join(home, ".config", "systemd", "user") + if entries, readErr := os.ReadDir(unit); readErr == nil && len(entries) > 0 { + t.Fatalf("supervisorInstall(%q) wrote %d unit file(s) despite refusing", args, len(entries)) + } + plist := filepath.Join(home, "Library", "LaunchAgents") + if entries, readErr := os.ReadDir(plist); readErr == nil && len(entries) > 0 { + t.Fatalf("supervisorInstall(%q) wrote %d plist(s) despite refusing", args, len(entries)) + } + }) + } +} + +// TestSupervisorInstallAcceptsListenFilters is the negative control: the guard +// must reject the process model only, not the filters the feature exists for. +func TestSupervisorInstallAcceptsListenFilters(t *testing.T) { + for _, option := range listenFilterOptions() { + name := optionLongName(option.Flags) + if err := rejectSupervisorProcessModelFlags([]string{"--" + name, "value"}); err != nil { + t.Errorf("supervisor install refuses the listen filter --%s: %v", name, err) + } + } +} diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index 9f129352..96b16338 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -744,7 +744,7 @@ func printHelpForArgs(args []string, stdout io.Writer) { case "stop", "off": fmt.Fprintln(stdout, "Usage: relayfile stop [WORKSPACE]") case "supervisor": - fmt.Fprintln(stdout, "Usage: relayfile supervisor [WORKSPACE] [--interval 30s]") + fmt.Fprintln(stdout, "Usage: relayfile supervisor [WORKSPACE] [LISTEN_FILTERS...]") case "logs": fmt.Fprintln(stdout, "Usage: relayfile logs [WORKSPACE] [--lines N]") case "observer": @@ -928,7 +928,7 @@ Usage: relayfile stop [WORKSPACE] relayfile off [WORKSPACE] (alias for stop) relayfile restart [WORKSPACE] [--foreground] - relayfile supervisor install [WORKSPACE] [--interval 30s] + relayfile supervisor install [WORKSPACE] [LISTEN_FILTERS...] relayfile supervisor uninstall [WORKSPACE] relayfile supervisor status [WORKSPACE] relayfile tree [WORKSPACE] [PATH] [--depth N] @@ -8726,7 +8726,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 --listen' to survive reboots.") + fmt.Fprintln(stdout, " Add --background to detach; 'relayfile supervisor install' to survive reboots.") } fmt.Fprintln(stdout) } @@ -9007,7 +9007,7 @@ On Linux it writes a systemd user unit (~/.config/systemd/user/relayfile-listen On macOS it writes a launchd agent (~/Library/LaunchAgents/com.relayfile.listen.plist). Usage: - relayfile supervisor install [LISTEN_FLAGS...] install and start the service + 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 @@ -9024,8 +9024,11 @@ Examples: relayfile supervisor status relayfile supervisor uninstall -All flags accepted by 'relayfile listen' are accepted here and are embedded -verbatim into the unit file. The service restarts automatically on failure.`) +The filters accepted by 'relayfile 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.`) } const ( @@ -9069,7 +9072,39 @@ func runSupervisor(args []string, stdout io.Writer) error { } } +// rejectSupervisorProcessModelFlags refuses the `listen` flags that choose a +// process model. supervisorInstall copies its argv verbatim into the unit's +// ExecStart, and the supervisor is already the thing that keeps the listener +// running: a unit that detaches exits on every start (see +// listenProcessModelOptions). The command table no longer advertises these, so +// a host parser built from the emitted spec rejects them first; this catches +// the same argv arriving straight at the binary. +func rejectSupervisorProcessModelFlags(listenArgs []string) error { + for _, arg := range listenArgs { + for _, name := range listenProcessModelFlagNames() { + if !argNamesFlag(arg, name) { + continue + } + return fmt.Errorf( + "supervisor install cannot embed --%s: it controls how `relayfile listen` runs, and the service already keeps the listener running; install the filters only and use 'relayfile supervisor status' to check on it", + name, + ) + } + } + return nil +} + +// argNamesFlag reports whether a single argv entry sets the named flag, in any +// of the spellings Go's flag package accepts. +func argNamesFlag(arg, name string) bool { + return arg == "--"+name || arg == "-"+name || + strings.HasPrefix(arg, "--"+name+"=") || strings.HasPrefix(arg, "-"+name+"=") +} + func supervisorInstall(listenArgs []string, stdout io.Writer) error { + if err := rejectSupervisorProcessModelFlags(listenArgs); err != nil { + return err + } executable, err := os.Executable() if err != nil { return fmt.Errorf("locate relayfile binary: %w", err) diff --git a/packages/sdk/typescript/src/relay-cli/command-spec.json b/packages/sdk/typescript/src/relay-cli/command-spec.json index 8a969112..159a049c 100644 --- a/packages/sdk/typescript/src/relay-cli/command-spec.json +++ b/packages/sdk/typescript/src/relay-cli/command-spec.json @@ -1311,16 +1311,6 @@ "flags": "--format ", "description": "output format when --run is not set: text or json", "defaultValue": "text" - }, - { - "flags": "--background", - "description": "run in background; logs to ~/.relayfile/listen.log", - "defaultValue": false - }, - { - "flags": "--daemonized", - "description": "internal flag used by relayfile listen --background", - "defaultValue": false } ] }, diff --git a/packages/sdk/typescript/src/relay-cli/go-run-build-path.test.ts b/packages/sdk/typescript/src/relay-cli/go-run-build-path.test.ts new file mode 100644 index 00000000..b99b77a1 --- /dev/null +++ b/packages/sdk/typescript/src/relay-cli/go-run-build-path.test.ts @@ -0,0 +1,202 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + utimesSync, + writeFileSync +} from "node:fs" +import path from "node:path" + +import { describe, expect, it } from "vitest" + +import { + buildGoRunBinary, + GoBuildFailedError, + type RelayfileGoRunResolution +} from "./index.js" +import { temporaryDirectory } from "./testing/build-binary.js" + +/** + * The source fallback must not fight over one file. + * + * `goRunBinaryPath` is keyed by the checkout alone, so every process that + * falls back to source builds to the same path — while a `listen` or `mount` + * started from that path is still executing it. Writing it in place cannot + * work on Windows (a running `.exe` is locked) and races on Unix, where a + * second builder can truncate the file a third process is about to exec. + * + * These tests pin the write itself: where `go build` is pointed, and what the + * caller gets back. `go` is a shim on PATH rather than the real toolchain, so + * the assertions are about this module's file handling and nothing else. + */ + +/** A fake `go` that records its `-o` target and writes a runnable file there. */ +function installGoShim(options: { exitCode?: number } = {}): { + pathEntry: string + outputTargets: () => string[] +} { + const binDir = temporaryDirectory("go-shim") + const log = path.join(temporaryDirectory("go-shim-log"), "targets.txt") + const script = `#!/bin/sh +out="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-o" ]; then out="$arg"; fi + prev="$arg" +done +printf '%s\\n' "$out" >> ${JSON.stringify(log)} +if [ ${options.exitCode ?? 0} -ne 0 ]; then + echo "shim: build refused" >&2 + exit ${options.exitCode ?? 0} +fi +if [ -d "$out" ]; then + echo "shim: $out is a directory" >&2 + exit 1 +fi +printf '#!/bin/sh\\necho relayfile-shim-build\\n' > "$out" || exit 1 +` + writeFileSync(path.join(binDir, "go"), script) + chmodSync(path.join(binDir, "go"), 0o755) + return { + pathEntry: binDir, + outputTargets: () => + existsSync(log) + ? readFileSync(log, "utf8") + .split("\n") + .filter((line) => line !== "") + : [] + } +} + +function goRunResolution(): RelayfileGoRunResolution { + return { + kind: "go-run", + command: "go", + args: ["run", "./cmd/relayfile-cli"], + cwd: temporaryDirectory("go-run-build-checkout") + } as RelayfileGoRunResolution +} + +/** A fresh, empty directory to publish into, plus the shared path inside it. */ +function buildDirectory(): { directory: string; output: string } { + const directory = temporaryDirectory("go-run-build-out") + return { directory, output: path.join(directory, "relayfile-cli") } +} + +describe.skipIf(process.platform === "win32")("source-fallback build output", () => { + it("builds to a private sibling and publishes it under the shared path", () => { + const go = installGoShim() + const { directory, output } = buildDirectory() + + const built = buildGoRunBinary(goRunResolution(), { + env: { PATH: go.pathEntry }, + outputPath: output + }) + + expect(built).toBe(output) + expect(readFileSync(output, "utf8")).toContain("relayfile-shim-build") + + // The bug: `go build -o` was pointed straight at the shared path, so a + // build overwrote whatever long-running process was executing it. + const targets = go.outputTargets() + expect(targets).toHaveLength(1) + expect(targets[0]).not.toBe(output) + expect(path.dirname(targets[0])).toBe(directory) + expect( + path.basename(targets[0]).startsWith(`${path.basename(output)}.build-`) + ).toBe(true) + + // Publishing is a rename, so nothing is left next to the result. + expect(readdirSync(directory)).toEqual([path.basename(output)]) + }) + + it("keeps the .exe extension on the staged build", () => { + // Windows is where the fallback below actually fires, and the path it + // returns is handed straight to the host to execute — so the staging + // suffix goes before the extension, not after it. + const go = installGoShim() + const { output } = buildDirectory() + const windowsOutput = `${output}.exe` + + buildGoRunBinary(goRunResolution(), { + env: { PATH: go.pathEntry }, + outputPath: windowsOutput + }) + + const staged = path.basename(go.outputTargets()[0]) + expect(staged).toMatch(/^relayfile-cli\.build-[0-9]+-[0-9a-f]+\.exe$/) + }) + + it("gives concurrent builds private paths, never a shared one", () => { + const go = installGoShim() + const { output } = buildDirectory() + const resolution = goRunResolution() + const env = { PATH: go.pathEntry } + + buildGoRunBinary(resolution, { env, outputPath: output }) + buildGoRunBinary(resolution, { env, outputPath: output }) + + const targets = go.outputTargets() + expect(targets).toHaveLength(2) + expect(new Set(targets).size).toBe(2) + expect(targets).not.toContain(output) + }) + + it("falls back to the private path when the shared name cannot be replaced", () => { + // Windows refuses to rename over a running `.exe`, and there is no way to + // provoke that on the platforms this suite runs on. An occupied + // destination stands in for it: what is under test is that a failed + // publish still yields a usable binary instead of an error. + const go = installGoShim() + const { directory, output } = buildDirectory() + mkdirSync(output, { recursive: true }) + writeFileSync(path.join(output, "occupant"), "held") + + const built = buildGoRunBinary(goRunResolution(), { + env: { PATH: go.pathEntry }, + outputPath: output + }) + + expect(built).not.toBe(output) + expect(path.dirname(built)).toBe(directory) + expect(readFileSync(built, "utf8")).toContain("relayfile-shim-build") + }) + + it("leaves nothing behind when the build fails", () => { + const go = installGoShim({ exitCode: 2 }) + const { directory, output } = buildDirectory() + + expect(() => + buildGoRunBinary(goRunResolution(), { + env: { PATH: go.pathEntry }, + outputPath: output + }) + ).toThrow(GoBuildFailedError) + + expect(readdirSync(directory)).toEqual([]) + }) + + it("sweeps stale artifacts a crash or a failed publish left behind", () => { + const go = installGoShim() + const { output } = buildDirectory() + + const stale = `${output}.build-999999-deadbeef` + writeFileSync(stale, "stale") + const old = new Date(Date.now() - 48 * 60 * 60 * 1000) + utimesSync(stale, old, old) + + const recent = `${output}.build-999998-feedface` + writeFileSync(recent, "recent") + + buildGoRunBinary(goRunResolution(), { + env: { PATH: go.pathEntry }, + outputPath: output + }) + + expect(existsSync(stale)).toBe(false) + // A fallback binary handed to a caller minutes ago may still be running. + expect(existsSync(recent)).toBe(true) + }) +}) diff --git a/packages/sdk/typescript/src/relay-cli/resolve-binary.ts b/packages/sdk/typescript/src/relay-cli/resolve-binary.ts index 6c4cae6d..811980e5 100644 --- a/packages/sdk/typescript/src/relay-cli/resolve-binary.ts +++ b/packages/sdk/typescript/src/relay-cli/resolve-binary.ts @@ -22,8 +22,15 @@ */ import { spawnSync } from "node:child_process" -import { createHash } from "node:crypto" -import { existsSync, mkdirSync } from "node:fs" +import { createHash, randomBytes } from "node:crypto" +import { + existsSync, + mkdirSync, + readdirSync, + renameSync, + rmSync, + statSync +} from "node:fs" import { createRequire } from "node:module" import os from "node:os" import path from "node:path" @@ -599,6 +606,17 @@ export function goRunBinaryPath( * the checkout, where the module is, and the binary runs wherever the caller * asked for. Go's build cache makes the repeat cost a relink. * + * The build never writes the shared path directly. That path is keyed by the + * checkout alone, so every process that falls back to source aims at the same + * file — and a `listen` or `mount` launched from it keeps running for hours. + * Writing it in place would mean overwriting a binary that is executing + * (impossible on Windows, and on Unix a window in which a concurrent build has + * replaced it with a partial file). So `go build` writes a private sibling and + * the result is published with one rename: atomic on POSIX, and an already + * running process keeps the inode it started from. If the rename cannot + * happen — Windows refuses to replace a running `.exe` — the caller gets the + * private path instead, which is just as runnable. + * * @param resolution - The `go-run` resolution to materialize. * @param options - Environment and output overrides. * @returns The absolute path of the built binary. @@ -611,21 +629,121 @@ export function buildGoRunBinary( ): string { const output = options.outputPath ?? goRunBinaryPath(resolution.cwd) mkdirSync(path.dirname(output), { recursive: true }) - - const result = spawnSync("go", ["build", "-o", output, "./cmd/relayfile-cli"], { + sweepStaleBuildArtifacts(output) + + // Same directory as the destination, so the publish below is a rename + // within one filesystem rather than a copy. pid plus random bytes: two + // builds in one process must not share it either. The destination's + // extension is kept last, because on Windows the fallback below hands this + // very path to the host to execute and that has to stay an `.exe`. + const staged = + `${buildArtifactPrefix(output)}${process.pid}-${randomBytes(6).toString("hex")}` + + path.extname(output) + + const result = spawnSync("go", ["build", "-o", staged, "./cmd/relayfile-cli"], { cwd: resolution.cwd, env: options.env ?? process.env, encoding: "utf8" }) if (result.error) { + discardBuildArtifact(staged) if ((result.error as NodeJS.ErrnoException).code === "ENOENT") { throw new GoToolchainMissingError() } throw result.error } if (result.status !== 0) { + discardBuildArtifact(staged) throw new GoBuildFailedError(result.status, result.stderr ?? "") } + + try { + renameSync(staged, output) + } catch { + // The shared name is held by something that cannot be replaced — a + // running binary on Windows. The staged build is a complete binary, so + // run that instead of failing the command. sweepStaleBuildArtifacts + // collects it later. + return staged + } return output } + +/** + * Prefix that marks a file in the build directory as a staged build. + * + * Inserted before the destination's extension rather than after it, so a + * staged `relayfile-cli.exe` is still named `...exe`. + * + * @param output - The shared build path. + * @returns The absolute path prefix every staged build starts with. + */ +function buildArtifactPrefix(output: string): string { + const extension = path.extname(output) + return path.join( + path.dirname(output), + `${path.basename(output, extension)}.build-` + ) +} + +/** How long an unpublished staged build is left alone before it is swept. */ +const STALE_BUILD_ARTIFACT_MS = 24 * 60 * 60 * 1000 + +/** + * Drop a staged build that was never published. + * + * Best effort throughout: a leftover artifact is wasted disk, never an error + * worth failing a command over. + * + * @param staged - The staged build path. + */ +function discardBuildArtifact(staged: string): void { + try { + rmSync(staged, { force: true }) + } catch { + // Ignore. + } +} + +/** + * Collect staged builds that were left behind. + * + * A staged build normally disappears into the rename that publishes it, and a + * failed build is removed on the spot. What is left is the case the rename + * could not happen (Windows, shared name in use) and the case a process died + * mid-build — neither of which cleans up after itself, and both of which + * would otherwise grow a binary-sized file per invocation forever. + * + * The age cutoff is what keeps this safe: a returned fallback binary may be + * executing right now, and only artifacts far older than any plausible build + * are removed. On Unix unlinking a running binary is harmless anyway; on + * Windows the delete simply fails and is ignored. + * + * @param output - The shared build path whose directory is swept. + */ +function sweepStaleBuildArtifacts(output: string): void { + const prefix = path.basename(buildArtifactPrefix(output)) + const directory = path.dirname(output) + let entries: string[] + try { + entries = readdirSync(directory) + } catch { + return + } + const cutoff = Date.now() - STALE_BUILD_ARTIFACT_MS + for (const entry of entries) { + if (!entry.startsWith(prefix)) { + continue + } + const candidate = path.join(directory, entry) + try { + if (statSync(candidate).mtimeMs > cutoff) { + continue + } + rmSync(candidate, { force: true }) + } catch { + // Ignore. + } + } +} From e45c194dba69ef5f0327a1dbd7d934ca8d8d194a Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Fri, 18 Sep 2026 07:07:19 -0700 Subject: [PATCH 14/14] chore(trail): record PR #507 round-three findings trajectory Co-Authored-By: Claude Opus 5 (1M context) Session-Id: d458bd97-53d8-4f02-be9c-48b67b93c916 --- .../2026-09}/traj_p2o6b2q8epj1.json | 28 +- .../completed/2026-09/traj_p2o6b2q8epj1.md | 44 +++ .../2026-09/traj_p2o6b2q8epj1.trace.json | 251 ++++++++++++++++++ .trajectories/index.json | 7 +- 4 files changed, 323 insertions(+), 7 deletions(-) rename .trajectories/{active => completed/2026-09}/traj_p2o6b2q8epj1.json (70%) create mode 100644 .trajectories/completed/2026-09/traj_p2o6b2q8epj1.md create mode 100644 .trajectories/completed/2026-09/traj_p2o6b2q8epj1.trace.json diff --git a/.trajectories/active/traj_p2o6b2q8epj1.json b/.trajectories/completed/2026-09/traj_p2o6b2q8epj1.json similarity index 70% rename from .trajectories/active/traj_p2o6b2q8epj1.json rename to .trajectories/completed/2026-09/traj_p2o6b2q8epj1.json index cdd9daab..235d742c 100644 --- a/.trajectories/active/traj_p2o6b2q8epj1.json +++ b/.trajectories/completed/2026-09/traj_p2o6b2q8epj1.json @@ -8,8 +8,9 @@ "id": "PR-507" } }, - "status": "active", + "status": "completed", "startedAt": "2026-09-18T13:58:18.225Z", + "completedAt": "2026-09-18T14:07:19.346Z", "agents": [ { "name": "default", @@ -23,6 +24,7 @@ "title": "Work", "agentName": "default", "startedAt": "2026-09-18T14:06:48.912Z", + "endedAt": "2026-09-18T14:07:19.346Z", "events": [ { "ts": 1789740408912, @@ -51,12 +53,30 @@ ] } ], - "commits": [], - "filesChanged": [], + "retrospective": { + "summary": "Split relayfile listen's spec into filters vs process-model flags so supervisor install cannot embed --background/--daemonized in a unit (plus a runtime refusal), and made the go-run source fallback build to a private sibling published by atomic rename, with a Windows fallback and an age-bounded sweep.", + "approach": "Standard approach", + "confidence": 0.85 + }, + "commits": [ + "95bbfc3f" + ], + "filesChanged": [ + ".trajectories/active/traj_p2o6b2q8epj1.json", + ".trajectories/index.json", + "cmd/relayfile-cli/commandspec.go", + "cmd/relayfile-cli/commandspec_test.go", + "cmd/relayfile-cli/listen_test.go", + "cmd/relayfile-cli/main.go", + "packages/sdk/typescript/src/relay-cli/command-spec.json", + "packages/sdk/typescript/src/relay-cli/go-run-build-path.test.ts", + "packages/sdk/typescript/src/relay-cli/resolve-binary.ts" + ], "projectId": "/home/khaliqgant/Projects/AgentWorkforce/relayfile", "tags": [], "_trace": { "startRef": "690ed387a5f8e374a714c9d8e95a1d5bdfc643ef", - "endRef": "690ed387a5f8e374a714c9d8e95a1d5bdfc643ef" + "endRef": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757", + "traceId": "6819828e-17cf-4fd9-9bc6-46a72df12d40" } } \ No newline at end of file diff --git a/.trajectories/completed/2026-09/traj_p2o6b2q8epj1.md b/.trajectories/completed/2026-09/traj_p2o6b2q8epj1.md new file mode 100644 index 00000000..c79fa46e --- /dev/null +++ b/.trajectories/completed/2026-09/traj_p2o6b2q8epj1.md @@ -0,0 +1,44 @@ +# Trajectory: PR #507 round-3 bugbot: supervisor install detach flags + go-run build path races + +> **Status:** ✅ Completed +> **Task:** PR-507 +> **Confidence:** 85% +> **Started:** September 18, 2026 at 06:58 AM +> **Completed:** September 18, 2026 at 07:07 AM + +--- + +## Summary + +Split relayfile listen's spec into filters vs process-model flags so supervisor install cannot embed --background/--daemonized in a unit (plus a runtime refusal), and made the go-run source fallback build to a private sibling published by atomic rename, with a Windows fallback and an age-bounded sweep. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Split listenOptions into filters vs process-model; supervisor install advertises filters only and rejects the rest at runtime +- **Chose:** Split listenOptions into filters vs process-model; supervisor install advertises filters only and rejects the rest at runtime +- **Reasoning:** runListen acts on --background/--daemonized before connecting, so they parse fine but break any unit that embeds them; the surface and the binary both have to say no + +### buildGoRunBinary stages to a private sibling and publishes by rename, with a returned-fallback and an age-bounded sweep +- **Chose:** buildGoRunBinary stages to a private sibling and publishes by rename, with a returned-fallback and an age-bounded sweep +- **Reasoning:** Shared path keyed by checkout alone is held open by long-running listen/mount; rename is atomic on POSIX and the fallback covers Windows, where replacing a running exe is impossible + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Split listenOptions into filters vs process-model; supervisor install advertises filters only and rejects the rest at runtime: Split listenOptions into filters vs process-model; supervisor install advertises filters only and rejects the rest at runtime +- buildGoRunBinary stages to a private sibling and publishes by rename, with a returned-fallback and an age-bounded sweep: buildGoRunBinary stages to a private sibling and publishes by rename, with a returned-fallback and an age-bounded sweep + +--- + +## Artifacts + +**Commits:** 95bbfc3f +**Files changed:** 9 diff --git a/.trajectories/completed/2026-09/traj_p2o6b2q8epj1.trace.json b/.trajectories/completed/2026-09/traj_p2o6b2q8epj1.trace.json new file mode 100644 index 00000000..0b618af4 --- /dev/null +++ b/.trajectories/completed/2026-09/traj_p2o6b2q8epj1.trace.json @@ -0,0 +1,251 @@ +{ + "version": "1.0.0", + "id": "6819828e-17cf-4fd9-9bc6-46a72df12d40", + "timestamp": "2026-09-18T14:07:19.370Z", + "trajectory": "traj_p2o6b2q8epj1", + "files": [ + { + "path": ".trajectories/active/traj_p2o6b2q8epj1.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 62, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + } + ] + } + ] + }, + { + "path": ".trajectories/index.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 6, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 400, + "end_line": 411, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/commandspec.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 6, + "end_line": 12, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 76, + "end_line": 89, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 639, + "end_line": 655, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 863, + "end_line": 874, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 877, + "end_line": 925, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 1037, + "end_line": 1055, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/commandspec_test.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 70, + "end_line": 76, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 192, + "end_line": 203, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 217, + "end_line": 240, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 290, + "end_line": 295, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/listen_test.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 3, + "end_line": 9, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 291, + "end_line": 297, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 302, + "end_line": 433, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + } + ] + } + ] + }, + { + "path": "cmd/relayfile-cli/main.go", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 744, + "end_line": 750, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 928, + "end_line": 934, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 8726, + "end_line": 8732, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 9007, + "end_line": 9013, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 9024, + "end_line": 9034, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 9072, + "end_line": 9110, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/command-spec.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1311, + "end_line": 1316, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/go-run-build-path.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 202, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + } + ] + } + ] + }, + { + "path": "packages/sdk/typescript/src/relay-cli/resolve-binary.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 22, + "end_line": 36, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 606, + "end_line": 622, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + }, + { + "start_line": 629, + "end_line": 749, + "revision": "95bbfc3f3c7c07a263bdbaa4005ca8e05428f757" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.trajectories/index.json b/.trajectories/index.json index 42b59b10..6182fc70 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-09-18T14:06:49.350Z", + "lastUpdated": "2026-09-18T14:07:19.400Z", "trajectories": { "traj_4pvrlmqfnzng": { "title": "Review PR #278 in AgentWorkforce/relayfile", @@ -403,9 +403,10 @@ }, "traj_p2o6b2q8epj1": { "title": "PR #507 round-3 bugbot: supervisor install detach flags + go-run build path races", - "status": "active", + "status": "completed", "startedAt": "2026-09-18T13:58:18.225Z", - "path": "/home/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/active/traj_p2o6b2q8epj1.json" + "completedAt": "2026-09-18T14:07:19.346Z", + "path": "/home/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/completed/2026-09/traj_p2o6b2q8epj1.json" } } } \ No newline at end of file