diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 272a5f92..42b89887 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,38 @@ jobs: working-directory: packages/sdk/typescript run: npm run test + client-typecheck: + name: Client Typecheck + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Check generated control-plane types + run: | + npm run codegen --workspace=@relayfile/client + git diff --exit-code -- packages/client/src/generated/control-plane.ts + + - name: Build client + run: npm run build --workspace=@relayfile/client + + - name: Typecheck client + run: npm run typecheck --workspace=@relayfile/client + + - name: Test client + run: npm run test --workspace=@relayfile/client + e2e: name: E2E runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2cc5d2ec..0fb86dd6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,6 +11,7 @@ on: - all - core - sdk + - client - agents - cli - file-observer @@ -157,6 +158,7 @@ jobs: const packagePaths = [ 'packages/core/package.json', 'packages/sdk/typescript/package.json', + 'packages/client/package.json', 'packages/agents/package.json', 'packages/cli/package.json', 'packages/file-observer/package.json', @@ -205,6 +207,7 @@ jobs: run: | npm run build --workspace=packages/core npm run build --workspace=packages/sdk/typescript + npm run build --workspace=@relayfile/client npm run build --workspace=packages/agents npm run build --workspace=packages/cli npm run build --workspace=packages/local-mount @@ -223,6 +226,9 @@ jobs: packages/sdk/typescript/package.json packages/sdk/typescript/dist/ packages/sdk/typescript/CHANGELOG.md + packages/client/package.json + packages/client/dist/ + packages/client/CHANGELOG.md packages/agents/package.json packages/agents/dist/ packages/agents/CHANGELOG.md @@ -342,6 +348,9 @@ jobs: - package: sdk path: packages/sdk/typescript mount_binary: "" + - package: client + path: packages/client + mount_binary: "" - package: agents path: packages/agents mount_binary: "" @@ -455,6 +464,7 @@ jobs: case "${{ github.event.inputs.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" ;; @@ -579,6 +589,7 @@ jobs: package.json package-lock.json \ packages/core/package.json packages/core/CHANGELOG.md \ packages/sdk/typescript/package.json packages/sdk/typescript/package-lock.json packages/sdk/typescript/CHANGELOG.md \ + packages/client/package.json packages/client/CHANGELOG.md \ packages/agents/package.json packages/agents/CHANGELOG.md \ packages/cli/package.json packages/cli/CHANGELOG.md \ packages/file-observer/package.json packages/file-observer/CHANGELOG.md \ @@ -604,6 +615,7 @@ jobs: ### Packages - `@relayfile/core@${{ needs.build.outputs.new_version }}` - `@relayfile/sdk@${{ needs.build.outputs.new_version }}` + - `@relayfile/client@${{ needs.build.outputs.new_version }}` - `@relayfile/agents@${{ needs.build.outputs.new_version }}` - `relayfile@${{ needs.build.outputs.new_version }}` - `@relayfile/file-observer@${{ needs.build.outputs.new_version }}` @@ -616,6 +628,7 @@ jobs: ### Install ```bash npm install @relayfile/sdk@${{ needs.build.outputs.new_version }} + npm install @relayfile/client@${{ needs.build.outputs.new_version }} npm install @relayfile/agents@${{ needs.build.outputs.new_version }} npm install relayfile@${{ needs.build.outputs.new_version }} npm install @relayfile/file-observer@${{ needs.build.outputs.new_version }} diff --git a/cmd/relayfile-cli/control_plane.go b/cmd/relayfile-cli/control_plane.go new file mode 100644 index 00000000..0c094e71 --- /dev/null +++ b/cmd/relayfile-cli/control_plane.go @@ -0,0 +1,571 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +const relayfileControlPlaneAPIVersion uint32 = 1 + +var relayfileControlPlaneSupportedVersions = []uint32{relayfileControlPlaneAPIVersion} + +type controlPlaneErrorCode string + +const ( + controlPlaneErrInvalidArgument controlPlaneErrorCode = "INVALID_ARGUMENT" + controlPlaneErrVersionIncompatible controlPlaneErrorCode = "VERSION_INCOMPATIBLE" + controlPlaneErrResourceUnresolved controlPlaneErrorCode = "RESOURCE_UNRESOLVED" + controlPlaneErrBindingNotFound controlPlaneErrorCode = "BINDING_NOT_FOUND" + controlPlaneErrDaemonUnavailable controlPlaneErrorCode = "DAEMON_UNAVAILABLE" +) + +type controlPlaneError struct { + Code controlPlaneErrorCode `json:"code"` + Message string `json:"message"` +} + +type helloRequest struct { + APIVersion uint32 `json:"apiVersion,omitempty"` +} + +type helloResponse struct { + DaemonVersion string `json:"daemonVersion"` + APIVersion uint32 `json:"apiVersion"` + SupportedAPIVersions []uint32 `json:"supportedApiVersions"` +} + +type listProvidersResponse struct { + Providers []integrationCatalogEntry `json:"providers"` +} + +type providerStatusRequest struct { + Provider string `json:"provider,omitempty"` + Workspace string `json:"workspace,omitempty"` + CloudAPIURL string `json:"cloudApiUrl,omitempty"` +} + +type connectProviderRequest struct { + Provider string `json:"provider"` + Workspace string `json:"workspace,omitempty"` + CloudAPIURL string `json:"cloudApiUrl,omitempty"` + Backend string `json:"backend,omitempty"` + NoOpen bool `json:"noOpen,omitempty"` + Timeout string `json:"timeout,omitempty"` + WaitSync bool `json:"waitSync,omitempty"` +} + +type connectProviderResponse struct { + Provider string `json:"provider"` + Output string `json:"output,omitempty"` +} + +type resolveResourcePathRequest struct { + Provider string `json:"provider"` + Resource string `json:"resource"` +} + +type resolveResourcePathResponse struct { + Provider string `json:"provider"` + Resource string `json:"resource"` + PathGlob string `json:"pathGlob"` + ResolvedExact bool `json:"resolvedExact"` + Warning string `json:"warning,omitempty"` +} + +type bindRequest struct { + Provider string `json:"provider"` + Resource string `json:"resource"` + Channel string `json:"channel"` + WebhookID string `json:"webhookId"` + WebhookToken string `json:"webhookToken"` + SubscriptionID string `json:"subscriptionId,omitempty"` +} + +type bindResponse struct { + Binding relayIntegrationBinding `json:"binding"` + Replaced bool `json:"replaced"` + Warning string `json:"warning,omitempty"` +} + +type listBindingsResponse struct { + Bindings []relayIntegrationBinding `json:"bindings"` +} + +type unbindRequest struct { + Provider string `json:"provider"` + Resource string `json:"resource,omitempty"` +} + +type writebackSecretRequest struct { + Workspace string `json:"workspace,omitempty"` + Channel string `json:"channel"` +} + +type writebackSecretData struct { + URL string `json:"url"` + Secret string `json:"secret"` +} + +func runControlPlane(args []string, stdout io.Writer) error { + if len(args) == 0 { + return errors.New("control-plane subcommand is required: serve") + } + switch args[0] { + case "serve": + return runControlPlaneServe(args[1:], stdout) + default: + return fmt.Errorf("unknown control-plane subcommand %q", args[0]) + } +} + +func runControlPlaneServe(args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("control-plane serve", flag.ContinueOnError) + fs.SetOutput(io.Discard) + sock := fs.String("sock", defaultRelayfileSocketPath(), "unix socket path") + if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{"sock": true})); err != nil { + return err + } + if fs.NArg() != 0 { + return errors.New("usage: relayfile control-plane serve [--sock PATH]") + } + return serveControlPlaneSocket(strings.TrimSpace(*sock), stdout) +} + +func serveControlPlaneSocket(sock string, stdout io.Writer) error { + if sock == "" { + sock = defaultRelayfileSocketPath() + } + if err := os.MkdirAll(filepath.Dir(sock), 0o700); err != nil { + return err + } + _ = os.Remove(sock) + listener, err := listenControlPlaneSocket(sock) + if err != nil { + return err + } + defer listener.Close() + defer os.Remove(sock) + if err := os.Chmod(sock, 0o600); err != nil { + return err + } + + server := &http.Server{ + Handler: newControlPlaneHandler(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + errCh := make(chan error, 1) + go func() { + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + return + } + errCh <- nil + }() + + fmt.Fprintf(stdout, "relayfile control-plane listening on %s\n", sock) + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigCh) + + select { + case sig := <-sigCh: + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + return err + } + if sig != os.Interrupt && sig != syscall.SIGTERM { + return fmt.Errorf("control-plane stopped by signal %s", sig) + } + return nil + case err := <-errCh: + return err + } +} + +func newControlPlaneHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/v1/hello", handleControlPlaneHello) + mux.HandleFunc("/v1/integrations/providers", withControlPlaneVersion(handleControlPlaneListProviders)) + mux.HandleFunc("/v1/integrations/provider-status", withControlPlaneVersion(handleControlPlaneProviderStatus)) + mux.HandleFunc("/v1/integrations/connect", withControlPlaneVersion(handleControlPlaneConnectProvider)) + mux.HandleFunc("/v1/integrations/resolve-path", withControlPlaneVersion(handleControlPlaneResolvePath)) + mux.HandleFunc("/v1/integrations/bind", withControlPlaneVersion(handleControlPlaneBind)) + mux.HandleFunc("/v1/integrations/bindings", withControlPlaneVersion(handleControlPlaneListBindings)) + mux.HandleFunc("/v1/integrations/unbind", withControlPlaneVersion(handleControlPlaneUnbind)) + mux.HandleFunc("/v1/integrations/writeback-secret", withControlPlaneVersion(handleControlPlaneWritebackSecret)) + return mux +} + +func withControlPlaneVersion(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := checkControlPlaneVersion(r); err != nil { + writeControlPlaneError(w, http.StatusUpgradeRequired, controlPlaneErrVersionIncompatible, err.Error()) + return + } + next(w, r) + } +} + +func handleControlPlaneHello(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + var requested uint32 + if r.Method == http.MethodPost && r.Body != nil { + var req helloRequest + if err := decodeControlPlaneJSON(r, &req); err != nil { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) + return + } + requested = req.APIVersion + } + if requested == 0 { + requested = controlPlaneVersionFromRequest(r) + } + if requested != 0 && !controlPlaneSupportsVersion(requested) { + writeControlPlaneError( + w, + http.StatusUpgradeRequired, + controlPlaneErrVersionIncompatible, + fmt.Sprintf("relayfile daemon speaks API v%d; this client needs v%d. Upgrade relayfile (or agent-relay).", relayfileControlPlaneAPIVersion, requested), + ) + return + } + writeControlPlaneJSON(w, http.StatusOK, controlPlaneHello()) +} + +func handleControlPlaneListProviders(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + writeControlPlaneJSON(w, http.StatusOK, listProvidersResponse{Providers: fallbackIntegrationCatalog()}) +} + +func handleControlPlaneProviderStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + req := providerStatusRequest{ + Provider: r.URL.Query().Get("provider"), + Workspace: r.URL.Query().Get("workspace"), + CloudAPIURL: r.URL.Query().Get("cloudApiUrl"), + } + if r.Method == http.MethodPost { + if err := decodeControlPlaneJSON(r, &req); err != nil { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) + return + } + } + provider := normalizeProviderID(req.Provider) + if provider == "" { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, "provider is required") + return + } + entries, err := controlPlaneListIntegrationConnections(req.Workspace, req.CloudAPIURL) + if err != nil { + writeControlPlaneMappedError(w, err) + return + } + for _, entry := range entries { + if normalizeProviderID(entry.Provider) == provider { + writeControlPlaneJSON(w, http.StatusOK, entry) + return + } + } + writeControlPlaneError(w, http.StatusNotFound, controlPlaneErrBindingNotFound, fmt.Sprintf("provider %q is not connected", provider)) +} + +func handleControlPlaneConnectProvider(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + var req connectProviderRequest + if err := decodeControlPlaneJSON(r, &req); err != nil { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) + return + } + provider := normalizeProviderID(req.Provider) + if provider == "" { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, "provider is required") + return + } + args := []string{provider} + if strings.TrimSpace(req.Workspace) != "" { + args = append(args, "--workspace", strings.TrimSpace(req.Workspace)) + } + if strings.TrimSpace(req.CloudAPIURL) != "" { + args = append(args, "--cloud-api-url", strings.TrimSpace(req.CloudAPIURL)) + } + if strings.TrimSpace(req.Backend) != "" { + args = append(args, "--backend", strings.TrimSpace(req.Backend)) + } + if req.NoOpen { + args = append(args, "--no-open") + } + if strings.TrimSpace(req.Timeout) != "" { + args = append(args, "--timeout", strings.TrimSpace(req.Timeout)) + } + if req.WaitSync { + args = append(args, "--wait-sync") + } + var out bytes.Buffer + if err := runIntegrationConnect(args, strings.NewReader(""), &out); err != nil { + writeControlPlaneMappedError(w, err) + return + } + writeControlPlaneJSON(w, http.StatusOK, connectProviderResponse{Provider: provider, Output: out.String()}) +} + +func handleControlPlaneResolvePath(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + var req resolveResourcePathRequest + if err := decodeControlPlaneJSON(r, &req); err != nil { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) + return + } + provider := normalizeProviderID(req.Provider) + if err := validateLocalProviderID(provider); err != nil { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) + return + } + resolved, err := resolveIntegrationBindPathGlob(provider, req.Resource) + if err != nil { + writeControlPlaneMappedError(w, err) + return + } + writeControlPlaneJSON(w, http.StatusOK, resolveResourcePathResponse{ + Provider: provider, + Resource: strings.TrimSpace(req.Resource), + PathGlob: resolved.PathGlob, + ResolvedExact: resolved.Warning == "", + Warning: resolved.Warning, + }) +} + +func handleControlPlaneBind(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + var req bindRequest + if err := decodeControlPlaneJSON(r, &req); err != nil { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) + return + } + binding, replaced, warning, err := bindRelayIntegration(relayIntegrationBindInput{ + Provider: req.Provider, + Resource: req.Resource, + Channel: req.Channel, + WebhookID: req.WebhookID, + WebhookToken: req.WebhookToken, + SubscriptionID: req.SubscriptionID, + }) + if err != nil { + writeControlPlaneMappedError(w, err) + return + } + writeControlPlaneJSON(w, http.StatusOK, bindResponse{Binding: binding, Replaced: replaced, Warning: warning}) +} + +func handleControlPlaneListBindings(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + bindings, err := listRelayIntegrationBindings() + if err != nil { + writeControlPlaneMappedError(w, err) + return + } + writeControlPlaneJSON(w, http.StatusOK, listBindingsResponse{Bindings: bindings}) +} + +func handleControlPlaneUnbind(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + var req unbindRequest + if err := decodeControlPlaneJSON(r, &req); err != nil { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) + return + } + result, err := unbindRelayIntegration(req.Provider, req.Resource) + if err != nil { + writeControlPlaneMappedError(w, err) + return + } + writeControlPlaneJSON(w, http.StatusOK, result) +} + +func handleControlPlaneWritebackSecret(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + var req writebackSecretRequest + if err := decodeControlPlaneJSON(r, &req); err != nil { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) + return + } + args := []string{"--channel", strings.TrimSpace(req.Channel), "--json"} + if strings.TrimSpace(req.Workspace) != "" { + args = append(args, "--workspace", strings.TrimSpace(req.Workspace)) + } + var out bytes.Buffer + if err := runIntegrationWritebackSecret(args, &out); err != nil { + writeControlPlaneMappedError(w, err) + return + } + var data writebackSecretData + if err := json.Unmarshal(out.Bytes(), &data); err != nil { + writeControlPlaneMappedError(w, fmt.Errorf("parse writeback secret response: %w", err)) + return + } + writeControlPlaneJSON(w, http.StatusOK, data) +} + +func controlPlaneHello() helloResponse { + return helloResponse{ + DaemonVersion: relayfileVersion, + APIVersion: relayfileControlPlaneAPIVersion, + SupportedAPIVersions: append([]uint32(nil), relayfileControlPlaneSupportedVersions...), + } +} + +func checkControlPlaneVersion(r *http.Request) error { + requested := controlPlaneVersionFromRequest(r) + if requested == 0 { + return nil + } + if !controlPlaneSupportsVersion(requested) { + return fmt.Errorf("relayfile daemon speaks API v%d; this client needs v%d. Upgrade relayfile (or agent-relay).", relayfileControlPlaneAPIVersion, requested) + } + return nil +} + +func controlPlaneVersionFromRequest(r *http.Request) uint32 { + raw := strings.TrimSpace(r.Header.Get("X-Relayfile-API-Version")) + if raw == "" { + raw = strings.TrimSpace(r.URL.Query().Get("apiVersion")) + } + if raw == "" { + return 0 + } + value, err := strconv.ParseUint(raw, 10, 32) + if err != nil { + return ^uint32(0) + } + return uint32(value) +} + +func controlPlaneSupportsVersion(version uint32) bool { + for _, supported := range relayfileControlPlaneSupportedVersions { + if supported == version { + return true + } + } + return false +} + +func controlPlaneListIntegrationConnections(workspace, cloudAPIURL string) ([]cloudIntegrationListEntry, error) { + args := []string{"--json"} + if strings.TrimSpace(workspace) != "" { + args = append(args, "--workspace", strings.TrimSpace(workspace)) + } + if strings.TrimSpace(cloudAPIURL) != "" { + args = append(args, "--cloud-api-url", strings.TrimSpace(cloudAPIURL)) + } + var out bytes.Buffer + if err := runIntegrationList(args, &out); err != nil { + return nil, err + } + var entries []cloudIntegrationListEntry + if err := json.Unmarshal(out.Bytes(), &entries); err != nil { + return nil, fmt.Errorf("parse integration list response: %w", err) + } + return entries, nil +} + +func decodeControlPlaneJSON(r *http.Request, out any) error { + defer r.Body.Close() + dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(out); err != nil { + return err + } + return nil +} + +func writeControlPlaneMappedError(w http.ResponseWriter, err error) { + message := err.Error() + status := http.StatusBadRequest + code := controlPlaneErrInvalidArgument + switch { + case strings.Contains(message, "no binding found"): + status = http.StatusNotFound + code = controlPlaneErrBindingNotFound + case strings.Contains(message, "PATH_GLOB") || strings.Contains(message, "resource"): + status = http.StatusUnprocessableEntity + code = controlPlaneErrResourceUnresolved + case strings.Contains(message, "connection refused") || strings.Contains(message, "credentials not found") || strings.Contains(message, "agent-relay"): + status = http.StatusServiceUnavailable + code = controlPlaneErrDaemonUnavailable + } + writeControlPlaneError(w, status, code, message) +} + +func writeControlPlaneError(w http.ResponseWriter, status int, code controlPlaneErrorCode, message string) { + writeControlPlaneJSON(w, status, map[string]controlPlaneError{ + "error": { + Code: code, + Message: message, + }, + }) +} + +func writeControlPlaneJSON(w http.ResponseWriter, status int, value any) { + payload, err := json.MarshalIndent(value, "", " ") + if err != nil { + status = http.StatusInternalServerError + payload = []byte(`{"error":{"code":"DAEMON_UNAVAILABLE","message":"failed to encode response"}}`) + } + payload = append(payload, '\n') + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(payload) +} + +func defaultRelayfileSocketPath() string { + if value := strings.TrimSpace(os.Getenv("RELAYFILE_SOCK")); value != "" { + return value + } + if value := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); value != "" { + return filepath.Join(value, "relayfile.sock") + } + return filepath.Join(os.TempDir(), "relayfile.sock") +} diff --git a/cmd/relayfile-cli/control_plane_socket_unix.go b/cmd/relayfile-cli/control_plane_socket_unix.go new file mode 100644 index 00000000..28c146b1 --- /dev/null +++ b/cmd/relayfile-cli/control_plane_socket_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package main + +import ( + "net" + "syscall" +) + +func listenControlPlaneSocket(sock string) (net.Listener, error) { + oldUmask := syscall.Umask(0o177) + defer syscall.Umask(oldUmask) + return net.Listen("unix", sock) +} diff --git a/cmd/relayfile-cli/control_plane_socket_windows.go b/cmd/relayfile-cli/control_plane_socket_windows.go new file mode 100644 index 00000000..0f06e823 --- /dev/null +++ b/cmd/relayfile-cli/control_plane_socket_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package main + +import "net" + +func listenControlPlaneSocket(sock string) (net.Listener, error) { + return net.Listen("unix", sock) +} diff --git a/cmd/relayfile-cli/control_plane_test.go b/cmd/relayfile-cli/control_plane_test.go new file mode 100644 index 00000000..57615ced --- /dev/null +++ b/cmd/relayfile-cli/control_plane_test.go @@ -0,0 +1,330 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestControlPlaneHelloVersionAndCLIVersion(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + var stdout bytes.Buffer + if err := run([]string{"--version"}, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatalf("relayfile --version failed: %v", err) + } + if got := strings.TrimSpace(stdout.String()); got != "0.10.17" { + t.Fatalf("relayfile --version = %q, want 0.10.17", got) + } + + client, baseURL, cleanup := startControlPlaneTestServer(t) + defer cleanup() + + var hello helloResponse + status := controlPlaneJSON(t, client, http.MethodGet, baseURL+"/v1/hello?apiVersion=1", nil, &hello) + if status != http.StatusOK { + t.Fatalf("hello status = %d", status) + } + if hello.DaemonVersion != "0.10.17" || hello.APIVersion != 1 { + t.Fatalf("unexpected hello response: %#v", hello) + } + + var errResp map[string]controlPlaneError + status = controlPlaneJSONWithoutVersion(t, client, http.MethodGet, baseURL+"/v1/hello?apiVersion=2", nil, &errResp) + if status != http.StatusUpgradeRequired { + t.Fatalf("incompatible hello status = %d, want %d", status, http.StatusUpgradeRequired) + } + if errResp["error"].Code != controlPlaneErrVersionIncompatible { + t.Fatalf("unexpected incompatible error: %#v", errResp) + } +} + +func TestControlPlaneBindingConformance(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + localDir := t.TempDir() + if _, err := upsertWorkspaceDetails(workspaceRecord{Name: "demo", ID: "ws_demo", LocalDir: localDir}); err != nil { + t.Fatalf("upsert workspace failed: %v", err) + } + writeTestMountFile(t, localDir, "slack/channels/by-name/watchdog-test.json", `{"id":"C123","canonicalPath":"/slack/channels/C123__watchdog-test/meta.json"}`) + + client, baseURL, cleanup := startControlPlaneTestServer(t) + defer cleanup() + + var resolved resolveResourcePathResponse + status := controlPlaneJSON(t, client, http.MethodPost, baseURL+"/v1/integrations/resolve-path", resolveResourcePathRequest{ + Provider: "slack", + Resource: "#watchdog-test", + }, &resolved) + if status != http.StatusOK { + t.Fatalf("resolve-path status = %d", status) + } + if resolved.PathGlob != "/slack/channels/C123__watchdog-test/**" || !resolved.ResolvedExact { + t.Fatalf("unexpected resolve-path response: %#v", resolved) + } + + var bound bindResponse + status = controlPlaneJSON(t, client, http.MethodPost, baseURL+"/v1/integrations/bind", bindRequest{ + Provider: "slack", + Resource: "#watchdog-test", + Channel: "#events", + WebhookID: "wh_1", + WebhookToken: "tok_1", + }, &bound) + if status != http.StatusOK { + t.Fatalf("bind status = %d", status) + } + if bound.Binding.PathGlob != resolved.PathGlob || bound.Binding.WebhookToken != "tok_1" { + t.Fatalf("unexpected bind response: %#v", bound) + } + + var listed listBindingsResponse + status = controlPlaneJSON(t, client, http.MethodGet, baseURL+"/v1/integrations/bindings", nil, &listed) + if status != http.StatusOK { + t.Fatalf("bindings status = %d", status) + } + if len(listed.Bindings) != 1 || listed.Bindings[0].PathGlob != resolved.PathGlob { + t.Fatalf("unexpected bindings response: %#v", listed) + } + + var stdout bytes.Buffer + if err := run([]string{"integration", "bind", "--list", "--json"}, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatalf("bind --list --json failed: %v\n%s", err, stdout.String()) + } + var cliBindings []relayIntegrationBinding + if err := json.Unmarshal(stdout.Bytes(), &cliBindings); err != nil { + t.Fatalf("parse bind --list --json output failed: %v\n%s", err, stdout.String()) + } + if len(cliBindings) != 1 || cliBindings[0].PathGlob != resolved.PathGlob { + t.Fatalf("unexpected CLI bindings: %#v", cliBindings) + } + + var unbound relayIntegrationUnbindResult + status = controlPlaneJSON(t, client, http.MethodPost, baseURL+"/v1/integrations/unbind", unbindRequest{ + Provider: "slack", + Resource: "#watchdog-test", + }, &unbound) + if status != http.StatusOK { + t.Fatalf("unbind status = %d", status) + } + if unbound.Removed != 1 || unbound.PathGlob != resolved.PathGlob { + t.Fatalf("unexpected unbind response: %#v", unbound) + } + + status = controlPlaneJSON(t, client, http.MethodGet, baseURL+"/v1/integrations/bindings", nil, &listed) + if status != http.StatusOK { + t.Fatalf("bindings after unbind status = %d", status) + } + if len(listed.Bindings) != 0 { + t.Fatalf("expected empty bindings after unbind, got %#v", listed) + } +} + +func TestControlPlaneCloudIntegrationConformance(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + localDir := t.TempDir() + if err := saveWorkspaceCatalog(workspaceCatalog{ + Default: "demo", + Workspaces: []workspaceRecord{{ + Name: "demo", + ID: "ws_123", + LocalDir: localDir, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + Scopes: append([]string(nil), defaultJoinScopes...), + }}, + }); err != nil { + t.Fatalf("save workspace catalog failed: %v", err) + } + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/workspaces/ws_123/integrations": + if r.Method != http.MethodGet { + t.Fatalf("expected integrations GET, got %s", r.Method) + } + _, _ = w.Write([]byte(`[{"provider":"github","status":"ready","connectionId":"conn_123"}]`)) + case "/v1/workspaces/ws_123/sync/status": + _, _ = w.Write([]byte(`{"workspaceId":"ws_123","providers":[{"provider":"github","status":"ready","lagSeconds":0}]}`)) + case "/api/v1/workspaces/ws_123/relayfile/delegated-token": + if r.Method != http.MethodPost { + t.Fatalf("expected delegated-token POST, got %s", r.Method) + } + writeDelegatedBundleResponse(t, w, server.URL, "ws_123", testJWTWithWorkspace("ws_123"), "refresh_join") + case "/api/v1/workspaces/ws_123/integrations/connect-session": + if r.Method != http.MethodPost { + t.Fatalf("expected connect-session POST, got %s", r.Method) + } + var body cloudConnectSessionRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode connect-session body failed: %v", err) + } + if len(body.AllowedIntegrations) != 1 || body.AllowedIntegrations[0] != "github" { + t.Fatalf("unexpected connect-session body: %#v", body) + } + _, _ = w.Write([]byte(`{"connectLink":"https://connect.test/github","connectionId":"conn_123"}`)) + case "/api/v1/workspaces/ws_123/integrations/github/status": + if r.URL.Query().Get("connectionId") != "conn_123" { + t.Fatalf("unexpected connectionId: %q", r.URL.Query().Get("connectionId")) + } + _, _ = w.Write([]byte(`{"ready":true,"provider":"github","connectionId":"conn_123","state":"ready"}`)) + case "/v1/workspaces/ws_123/integrations/relay/writeback-secret": + if r.URL.Query().Get("channel") != "#events" { + t.Fatalf("unexpected writeback-secret channel: %q", r.URL.Query().Get("channel")) + } + _, _ = w.Write([]byte(`{"ok":true,"data":{"url":"https://relay.test/writeback","secret":"sec_123"}}`)) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + installFakeAgentRelaySession(t, server.URL, "cld_test", "demo", "ws_123", "ws_123") + + client, baseURL, cleanup := startControlPlaneTestServer(t) + defer cleanup() + + var providers listProvidersResponse + status := controlPlaneJSON(t, client, http.MethodGet, baseURL+"/v1/integrations/providers", nil, &providers) + if status != http.StatusOK { + t.Fatalf("providers status = %d", status) + } + if len(providers.Providers) == 0 { + t.Fatalf("expected fallback providers") + } + + var connected connectProviderResponse + status = controlPlaneJSON(t, client, http.MethodPost, baseURL+"/v1/integrations/connect", connectProviderRequest{ + Provider: "github", + Workspace: "demo", + NoOpen: true, + Timeout: "250ms", + }, &connected) + if status != http.StatusOK { + t.Fatalf("connect status = %d", status) + } + if connected.Provider != "github" || !strings.Contains(connected.Output, "github connected") { + t.Fatalf("unexpected connect response: %#v", connected) + } + + var statusEntry cloudIntegrationListEntry + status = controlPlaneJSON(t, client, http.MethodGet, baseURL+"/v1/integrations/provider-status?provider=github&workspace=demo", nil, &statusEntry) + if status != http.StatusOK { + t.Fatalf("provider-status status = %d", status) + } + if statusEntry.Provider != "github" || statusEntry.Status != "ready" { + t.Fatalf("unexpected provider status: %#v", statusEntry) + } + + var secret writebackSecretData + status = controlPlaneJSON(t, client, http.MethodPost, baseURL+"/v1/integrations/writeback-secret", writebackSecretRequest{ + Workspace: "demo", + Channel: "#events", + }, &secret) + if status != http.StatusOK { + t.Fatalf("writeback-secret status = %d", status) + } + if secret.URL != "https://relay.test/writeback" || secret.Secret != "sec_123" { + t.Fatalf("unexpected writeback secret: %#v", secret) + } +} + +func startControlPlaneTestServer(t *testing.T) (*http.Client, string, func()) { + t.Helper() + sock := filepath.Join(os.TempDir(), fmt.Sprintf("rfcp-%d-%d.sock", os.Getpid(), time.Now().UnixNano())) + t.Cleanup(func() { _ = os.Remove(sock) }) + listener, err := net.Listen("unix", sock) + if err != nil { + t.Fatalf("listen unix socket failed: %v", err) + } + server := &http.Server{Handler: newControlPlaneHandler()} + errCh := make(chan error, 1) + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + errCh <- err + return + } + errCh <- nil + }() + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, "unix", sock) + }, + } + client := &http.Client{Transport: transport} + cleanup := func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = server.Shutdown(ctx) + transport.CloseIdleConnections() + select { + case err := <-errCh: + if err != nil { + t.Fatalf("control-plane server failed: %v", err) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for control-plane server shutdown") + } + _ = os.Remove(sock) + } + return client, "http://relayfile.test", cleanup +} + +func controlPlaneJSON(t *testing.T, client *http.Client, method, url string, body any, out any) int { + t.Helper() + return controlPlaneJSONWithVersionHeader(t, client, method, url, body, out, true) +} + +func controlPlaneJSONWithoutVersion(t *testing.T, client *http.Client, method, url string, body any, out any) int { + t.Helper() + return controlPlaneJSONWithVersionHeader(t, client, method, url, body, out, false) +} + +func controlPlaneJSONWithVersionHeader(t *testing.T, client *http.Client, method, url string, body any, out any, sendVersion bool) int { + t.Helper() + var reader *bytes.Reader + if body == nil { + reader = bytes.NewReader(nil) + } else { + payload, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request failed: %v", err) + } + reader = bytes.NewReader(payload) + } + req, err := http.NewRequest(method, url, reader) + if err != nil { + t.Fatalf("new request failed: %v", err) + } + if sendVersion { + req.Header.Set("X-Relayfile-API-Version", "1") + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("control-plane request failed: %v", err) + } + defer resp.Body.Close() + if out != nil { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + t.Fatalf("decode response failed with status %d: %v", resp.StatusCode, err) + } + } + return resp.StatusCode +} diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index 2bdffce1..d46b37f7 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -43,6 +43,7 @@ import ( ) const ( + relayfileDefaultVersion = "0.10.17" defaultServerURL = "https://file.agentrelay.com" defaultCloudAPIURL = "https://agentrelay.com/cloud" defaultObserverURL = "https://agentrelay.com/observer/file" @@ -55,6 +56,10 @@ const ( defaultMountTimeout = 15 * time.Second ) +var relayfileVersion = relayfileDefaultVersion + +var relayIntegrationBindingsMu sync.Mutex + // defaultJoinScopes are the scopes minted for every delegated-credential // workspace join. ops:read is required for writeback op-status polling // (/v1/workspaces/{id}/ops/{opId}); sync:trigger is required for @@ -520,6 +525,10 @@ func main() { } func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { + if wantsVersion(args) { + fmt.Fprintln(stdout, relayfileVersion) + return nil + } if wantsHelp(args) { printHelpForArgs(args, stdout) return nil @@ -576,6 +585,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { 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": @@ -587,6 +598,10 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { } } +func wantsVersion(args []string) bool { + return len(args) == 1 && (args[0] == "--version" || args[0] == "version") +} + func wantsHelp(args []string) bool { for _, arg := range args { if arg == "-h" || arg == "--help" { @@ -654,6 +669,8 @@ func printHelpForArgs(args []string, stdout io.Writer) { fmt.Fprintln(stdout, "Usage: relayfile observer [WORKSPACE] [--no-open]") case "listen", "watch": fmt.Fprintln(stdout, "Usage: relayfile listen [WORKSPACE] [--provider PROVIDER] [--path GLOB] [--event TYPE] [--run CMD] [--format text|json] [--background]") + case "control-plane": + fmt.Fprintln(stdout, "Usage: relayfile control-plane serve [--sock PATH]") case "dev": fmt.Fprintln(stdout, "Usage: relayfile dev [WORKSPACE] [--provider PROVIDER] [--path GLOB] [--event TYPE] [--run CMD]") case "help": @@ -836,6 +853,7 @@ Usage: relayfile status [WORKSPACE] relayfile logs [WORKSPACE] relayfile observer [WORKSPACE] [--no-open] + relayfile control-plane serve [--sock PATH] Subcommands: setup Sign in, connect an integration, and mount the workspace @@ -2300,65 +2318,47 @@ func runIntegration(args []string, stdin io.Reader, stdout io.Writer) error { } } -func runIntegrationBind(args []string, stdout io.Writer) error { - fs := flag.NewFlagSet("integration bind", flag.ContinueOnError) - fs.SetOutput(io.Discard) - list := fs.Bool("list", false, "list active relay bindings as JSON") - channel := fs.String("channel", "", "relay channel to receive provider records") - webhookID := fs.String("webhook", "", "RelayCast inbound webhook id") - webhookToken := fs.String("webhook-token", "", "RelayCast inbound webhook token") - subscriptionID := fs.String("subscription", "", "relay integration subscription id") - if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{ - "list": false, - "channel": true, - "webhook": true, - "webhook-token": true, - "subscription": true, - })); err != nil { - return err - } - if *list { - if fs.NArg() != 0 { - return errors.New("usage: relayfile integration bind --list") - } - bindings, err := readRelayIntegrationBindings() - if err != nil { - return err - } - return writeJSON(stdout, bindings) - } - if fs.NArg() != 2 { - return errors.New("usage: relayfile integration bind PROVIDER RESOURCE_OR_PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN") - } - provider := normalizeProviderID(fs.Arg(0)) +type relayIntegrationBindInput struct { + Provider string + Resource string + Channel string + WebhookID string + WebhookToken string + SubscriptionID string +} + +func bindRelayIntegration(input relayIntegrationBindInput) (relayIntegrationBinding, bool, string, error) { + provider := normalizeProviderID(input.Provider) if err := validateLocalProviderID(provider); err != nil { - return err + return relayIntegrationBinding{}, false, "", err } - resolved, err := resolveIntegrationBindPathGlob(provider, fs.Arg(1)) + resolved, err := resolveIntegrationBindPathGlob(provider, input.Resource) if err != nil { - return err + return relayIntegrationBinding{}, false, "", err } binding := relayIntegrationBinding{ Provider: provider, PathGlob: resolved.PathGlob, - Channel: strings.TrimSpace(*channel), - WebhookID: strings.TrimSpace(*webhookID), - WebhookToken: strings.TrimSpace(*webhookToken), - SubscriptionID: strings.TrimSpace(*subscriptionID), + Channel: strings.TrimSpace(input.Channel), + WebhookID: strings.TrimSpace(input.WebhookID), + WebhookToken: strings.TrimSpace(input.WebhookToken), + SubscriptionID: strings.TrimSpace(input.SubscriptionID), } if binding.Channel == "" { - return errors.New("--channel is required") + return relayIntegrationBinding{}, false, "", errors.New("--channel is required") } if binding.WebhookID == "" { - return errors.New("--webhook is required") + return relayIntegrationBinding{}, false, "", errors.New("--webhook is required") } if binding.WebhookToken == "" { - return errors.New("--webhook-token is required") + return relayIntegrationBinding{}, false, "", errors.New("--webhook-token is required") } + relayIntegrationBindingsMu.Lock() + defer relayIntegrationBindingsMu.Unlock() now := time.Now().UTC().Format(time.RFC3339) bindings, err := readRelayIntegrationBindings() if err != nil { - return err + return relayIntegrationBinding{}, false, "", err } replaced := false for i := range bindings { @@ -2382,10 +2382,56 @@ func runIntegrationBind(args []string, stdout io.Writer) error { bindings = append(bindings, binding) } if err := writeRelayIntegrationBindings(bindings); err != nil { + return relayIntegrationBinding{}, false, "", err + } + return binding, replaced, resolved.Warning, nil +} + +func runIntegrationBind(args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("integration bind", flag.ContinueOnError) + fs.SetOutput(io.Discard) + list := fs.Bool("list", false, "list active relay bindings as JSON") + _ = fs.Bool("json", false, "accepted for consistency with other JSON-emitting integration commands") + channel := fs.String("channel", "", "relay channel to receive provider records") + webhookID := fs.String("webhook", "", "RelayCast inbound webhook id") + webhookToken := fs.String("webhook-token", "", "RelayCast inbound webhook token") + subscriptionID := fs.String("subscription", "", "relay integration subscription id") + if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{ + "list": false, + "channel": true, + "webhook": true, + "webhook-token": true, + "subscription": true, + "json": false, + })); err != nil { return err } - if resolved.Warning != "" { - fmt.Fprintf(stdout, "Warning: %s\n", resolved.Warning) + if *list { + if fs.NArg() != 0 { + return errors.New("usage: relayfile integration bind --list") + } + bindings, err := listRelayIntegrationBindings() + if err != nil { + return err + } + return writeJSON(stdout, bindings) + } + if fs.NArg() != 2 { + return errors.New("usage: relayfile integration bind PROVIDER RESOURCE_OR_PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN") + } + binding, replaced, warning, err := bindRelayIntegration(relayIntegrationBindInput{ + Provider: fs.Arg(0), + Resource: fs.Arg(1), + Channel: *channel, + WebhookID: *webhookID, + WebhookToken: *webhookToken, + SubscriptionID: *subscriptionID, + }) + if err != nil { + return err + } + if warning != "" { + fmt.Fprintf(stdout, "Warning: %s\n", warning) } if replaced { fmt.Fprintf(stdout, "%s binding updated: %s -> %s\n", binding.Provider, binding.PathGlob, binding.Channel) @@ -2435,48 +2481,39 @@ func runIntegrationResolvePath(args []string, stdout io.Writer) error { return nil } -func runIntegrationUnbind(args []string, stdout io.Writer) error { - fs := flag.NewFlagSet("integration unbind", flag.ContinueOnError) - fs.SetOutput(io.Discard) - resource := fs.String("resource", "", "path glob/resource to unbind") - if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{ - "resource": true, - })); err != nil { - return err - } - if fs.NArg() < 1 || fs.NArg() > 2 { - return errors.New("usage: relayfile integration unbind PROVIDER [RESOURCE_OR_PATH_GLOB|--resource RESOURCE_OR_PATH_GLOB]") - } - provider := normalizeProviderID(fs.Arg(0)) +type relayIntegrationUnbindResult struct { + Provider string + PathGlob string + Removed int + Warning string +} + +func unbindRelayIntegration(providerValue, resourceValue string) (relayIntegrationUnbindResult, error) { + provider := normalizeProviderID(providerValue) if err := validateLocalProviderID(provider); err != nil { - return err - } - pathGlob := strings.TrimSpace(*resource) - if fs.NArg() == 2 { - if pathGlob != "" { - return errors.New("pass RESOURCE_OR_PATH_GLOB either positionally or with --resource, not both") - } - pathGlob = strings.TrimSpace(fs.Arg(1)) + return relayIntegrationUnbindResult{}, err } + pathGlob := strings.TrimSpace(resourceValue) matchGlobs := []string{} if pathGlob != "" { matchGlobs = append(matchGlobs, pathGlob) } + warning := "" nativeResource := pathGlob != "" && !strings.HasPrefix(pathGlob, "/") if nativeResource { resolved, err := resolveIntegrationBindPathGlob(provider, pathGlob) if err != nil { - return err + return relayIntegrationUnbindResult{}, err } pathGlob = resolved.PathGlob matchGlobs = append([]string{pathGlob}, fallbackUnbindPathGlobsForNativeResource(provider)...) - if resolved.Warning != "" { - fmt.Fprintf(stdout, "Warning: %s\n", resolved.Warning) - } + warning = resolved.Warning } + relayIntegrationBindingsMu.Lock() + defer relayIntegrationBindingsMu.Unlock() bindings, err := readRelayIntegrationBindings() if err != nil { - return err + return relayIntegrationUnbindResult{}, err } kept := bindings[:0] removed := 0 @@ -2489,17 +2526,51 @@ func runIntegrationUnbind(args []string, stdout io.Writer) error { } if removed == 0 { if pathGlob != "" { - return fmt.Errorf("no binding found for provider %q with path glob %q", provider, pathGlob) + return relayIntegrationUnbindResult{}, fmt.Errorf("no binding found for provider %q with path glob %q", provider, pathGlob) } - return fmt.Errorf("no binding found for provider %q", provider) + return relayIntegrationUnbindResult{}, fmt.Errorf("no binding found for provider %q", provider) } if err := writeRelayIntegrationBindings(kept); err != nil { + return relayIntegrationUnbindResult{}, err + } + return relayIntegrationUnbindResult{ + Provider: provider, + PathGlob: pathGlob, + Removed: removed, + Warning: warning, + }, nil +} + +func runIntegrationUnbind(args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("integration unbind", flag.ContinueOnError) + fs.SetOutput(io.Discard) + resource := fs.String("resource", "", "path glob/resource to unbind") + if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{ + "resource": true, + })); err != nil { return err } - if pathGlob == "" { - fmt.Fprintf(stdout, "%s bindings removed: %d\n", provider, removed) + if fs.NArg() < 1 || fs.NArg() > 2 { + return errors.New("usage: relayfile integration unbind PROVIDER [RESOURCE_OR_PATH_GLOB|--resource RESOURCE_OR_PATH_GLOB]") + } + pathGlob := strings.TrimSpace(*resource) + if fs.NArg() == 2 { + if pathGlob != "" { + return errors.New("pass RESOURCE_OR_PATH_GLOB either positionally or with --resource, not both") + } + pathGlob = strings.TrimSpace(fs.Arg(1)) + } + result, err := unbindRelayIntegration(fs.Arg(0), pathGlob) + if err != nil { + return err + } + if result.Warning != "" { + fmt.Fprintf(stdout, "Warning: %s\n", result.Warning) + } + if result.PathGlob == "" { + fmt.Fprintf(stdout, "%s bindings removed: %d\n", result.Provider, result.Removed) } else { - fmt.Fprintf(stdout, "%s binding removed: %s\n", provider, pathGlob) + fmt.Fprintf(stdout, "%s binding removed: %s\n", result.Provider, result.PathGlob) } return nil } @@ -7829,6 +7900,12 @@ func relayIntegrationBindingsPath() string { return filepath.Join(configDir(), "bindings.json") } +func listRelayIntegrationBindings() ([]relayIntegrationBinding, error) { + relayIntegrationBindingsMu.Lock() + defer relayIntegrationBindingsMu.Unlock() + return readRelayIntegrationBindings() +} + func readRelayIntegrationBindings() ([]relayIntegrationBinding, error) { payload, err := os.ReadFile(relayIntegrationBindingsPath()) if err != nil { diff --git a/openapi/relayfile-control-plane-v1.openapi.yaml b/openapi/relayfile-control-plane-v1.openapi.yaml new file mode 100644 index 00000000..65f5aac8 --- /dev/null +++ b/openapi/relayfile-control-plane-v1.openapi.yaml @@ -0,0 +1,505 @@ +openapi: 3.1.0 +info: + title: Relayfile Local Control Plane API + version: 1.0.0 + description: | + Versioned local control-plane contract for the relayfile daemon/CLI. + The service listens on a user-owned Unix domain socket + (`RELAYFILE_SOCK`, or `$XDG_RUNTIME_DIR/relayfile.sock`) and uses + `X-Relayfile-API-Version: 1` for client compatibility checks. +servers: + - url: http://relayfile.local + description: Logical HTTP origin over the relayfile Unix domain socket. +paths: + /v1/hello: + get: + operationId: hello + summary: Negotiate control-plane API compatibility + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + - $ref: "#/components/parameters/ApiVersionQuery" + responses: + "200": + description: Daemon version and supported API versions + content: + application/json: + schema: + $ref: "#/components/schemas/HelloResponse" + "426": + $ref: "#/components/responses/VersionIncompatible" + post: + operationId: helloPost + summary: Negotiate control-plane API compatibility + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/HelloRequest" + responses: + "200": + description: Daemon version and supported API versions + content: + application/json: + schema: + $ref: "#/components/schemas/HelloResponse" + "426": + $ref: "#/components/responses/VersionIncompatible" + /v1/integrations/providers: + get: + operationId: listProviders + summary: List known integration providers + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + responses: + "200": + description: Provider catalog + content: + application/json: + schema: + type: object + required: [providers] + properties: + providers: + type: array + items: + $ref: "#/components/schemas/Provider" + /v1/integrations/provider-status: + get: + operationId: getProviderStatus + summary: Get connected provider status for a workspace + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + - name: provider + in: query + required: true + schema: + type: string + - name: workspace + in: query + required: false + schema: + type: string + - name: cloudApiUrl + in: query + required: false + schema: + type: string + responses: + "200": + description: Provider status + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderStatus" + "404": + $ref: "#/components/responses/NotFound" + post: + operationId: getProviderStatusPost + summary: Get connected provider status for a workspace + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderStatusRequest" + responses: + "200": + description: Provider status + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderStatus" + "404": + $ref: "#/components/responses/NotFound" + /v1/integrations/connect: + post: + operationId: connectProvider + summary: Start or reuse a provider connection + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ConnectProviderRequest" + responses: + "200": + description: Connection command completed + content: + application/json: + schema: + $ref: "#/components/schemas/ConnectProviderResponse" + /v1/integrations/resolve-path: + post: + operationId: resolveResourcePath + summary: Resolve a provider-native resource to a canonical VFS glob + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveResourcePathRequest" + responses: + "200": + description: Resolved path glob + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveResourcePathResponse" + "422": + $ref: "#/components/responses/ResourceUnresolved" + /v1/integrations/bind: + post: + operationId: bind + summary: Upsert a relay integration binding + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BindRequest" + responses: + "200": + description: Created or updated binding + content: + application/json: + schema: + $ref: "#/components/schemas/BindResponse" + /v1/integrations/bindings: + get: + operationId: listBindings + summary: List relay integration bindings + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + responses: + "200": + description: Existing bindings without one-time webhook token redaction + content: + application/json: + schema: + $ref: "#/components/schemas/ListBindingsResponse" + /v1/integrations/unbind: + post: + operationId: unbind + summary: Remove relay integration bindings + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UnbindRequest" + responses: + "200": + description: Removed binding count + content: + application/json: + schema: + $ref: "#/components/schemas/UnbindResponse" + "404": + $ref: "#/components/responses/NotFound" + /v1/integrations/writeback-secret: + post: + operationId: getWritebackBinding + summary: Fetch the per-channel writeback URL and signing secret + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WritebackSecretRequest" + responses: + "200": + description: Writeback URL and secret + content: + application/json: + schema: + $ref: "#/components/schemas/WritebackSecret" +components: + parameters: + ApiVersionHeader: + name: X-Relayfile-API-Version + in: header + required: false + schema: + type: integer + format: uint32 + const: 1 + ApiVersionQuery: + name: apiVersion + in: query + required: false + schema: + type: integer + format: uint32 + responses: + VersionIncompatible: + description: Client and daemon API versions are incompatible + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + NotFound: + description: Requested provider or binding was not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + ResourceUnresolved: + description: Provider-native resource could not be resolved + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + schemas: + HelloRequest: + type: object + properties: + apiVersion: + type: integer + format: uint32 + HelloResponse: + type: object + required: [daemonVersion, apiVersion, supportedApiVersions] + properties: + daemonVersion: + type: string + apiVersion: + type: integer + format: uint32 + supportedApiVersions: + type: array + items: + type: integer + format: uint32 + Provider: + type: object + required: [id] + properties: + id: + type: string + displayName: + type: string + configKey: + type: string + vfsRoot: + type: string + deprecated: + type: boolean + backend: + type: string + backends: + type: array + items: + type: string + sources: + type: array + items: + type: string + authMode: + type: string + categories: + type: array + items: + type: string + docs: + type: string + ProviderStatusRequest: + type: object + required: [provider] + properties: + provider: + type: string + workspace: + type: string + cloudApiUrl: + type: string + ProviderStatus: + type: object + required: [provider] + properties: + provider: + type: string + status: + type: string + lagSeconds: + type: integer + lastEventAt: + type: string + connectionId: + type: string + webhookHealthy: + type: boolean + deprecated: + type: boolean + ConnectProviderRequest: + type: object + required: [provider] + properties: + provider: + type: string + workspace: + type: string + cloudApiUrl: + type: string + backend: + type: string + noOpen: + type: boolean + timeout: + type: string + waitSync: + type: boolean + ConnectProviderResponse: + type: object + required: [provider] + properties: + provider: + type: string + output: + type: string + ResolveResourcePathRequest: + type: object + required: [provider, resource] + properties: + provider: + type: string + resource: + type: string + ResolveResourcePathResponse: + type: object + required: [provider, resource, pathGlob, resolvedExact] + properties: + provider: + type: string + resource: + type: string + pathGlob: + type: string + pattern: "^/" + resolvedExact: + type: boolean + warning: + type: string + BindRequest: + type: object + required: [provider, resource, channel, webhookId, webhookToken] + properties: + provider: + type: string + resource: + type: string + channel: + type: string + webhookId: + type: string + webhookToken: + type: string + subscriptionId: + type: string + BindResponse: + type: object + required: [binding, replaced] + properties: + binding: + $ref: "#/components/schemas/Binding" + replaced: + type: boolean + warning: + type: string + Binding: + type: object + required: [provider, pathGlob, channel, webhookId, webhookToken] + properties: + provider: + type: string + pathGlob: + type: string + pattern: "^/" + channel: + type: string + webhookId: + type: string + webhookToken: + type: string + subscriptionId: + type: string + createdAt: + type: string + updatedAt: + type: string + ListBindingsResponse: + type: object + required: [bindings] + properties: + bindings: + type: array + items: + $ref: "#/components/schemas/Binding" + UnbindRequest: + type: object + required: [provider] + properties: + provider: + type: string + resource: + type: string + UnbindResponse: + type: object + required: [provider, pathGlob, removed] + properties: + provider: + type: string + pathGlob: + type: string + removed: + type: integer + warning: + type: string + WritebackSecretRequest: + type: object + required: [channel] + properties: + workspace: + type: string + channel: + type: string + WritebackSecret: + type: object + required: [url, secret] + properties: + url: + type: string + secret: + type: string + ErrorEnvelope: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: + type: string + enum: + - INVALID_ARGUMENT + - VERSION_INCOMPATIBLE + - RESOURCE_UNRESOLVED + - BINDING_NOT_FOUND + - DAEMON_UNAVAILABLE + message: + type: string diff --git a/package-lock.json b/package-lock.json index 07a83f27..2a96572f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "relayfile", - "version": "0.10.16", + "version": "0.10.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "relayfile", - "version": "0.10.16", + "version": "0.10.17", "license": "Apache-2.0", "workspaces": [ "packages/core", "packages/sdk/typescript", + "packages/client", "packages/cli", "packages/local-mount", "packages/file-observer", @@ -855,7 +856,6 @@ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -870,8 +870,7 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", @@ -879,7 +878,6 @@ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -2359,6 +2357,52 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.16", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.16.tgz", + "integrity": "sha512-zIgmQTT2TV/U/SJ3N4jlIw36erH6X8ga1UNIoyrlbr0yLEbsiII/16LZ0kMxWu2A8pw0xd56rwTz5sMudy2OAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.2.0", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, "node_modules/@relaycast/sdk": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@relaycast/sdk/-/sdk-1.1.0.tgz", @@ -2382,6 +2426,10 @@ "resolved": "packages/agents", "link": true }, + "node_modules/@relayfile/client": { + "resolved": "packages/client", + "link": true + }, "node_modules/@relayfile/core": { "resolved": "packages/core", "link": true @@ -2395,8 +2443,8 @@ "link": true }, "node_modules/@relayfile/mount-darwin-arm64": { - "version": "0.10.16", - "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-arm64/-/mount-darwin-arm64-0.10.16.tgz", + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-arm64/-/mount-darwin-arm64-0.10.17.tgz", "integrity": "sha512-1CzCENOqD+58ncowtNIoLpFkWuBA5VeX0csxXeKz27SfqAS1xb15CRvUIlFwY1J4uv3V6nuDFjmc73ppCuTkAg==", "cpu": [ "arm64" @@ -2408,8 +2456,8 @@ ] }, "node_modules/@relayfile/mount-darwin-x64": { - "version": "0.10.16", - "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-x64/-/mount-darwin-x64-0.10.16.tgz", + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-x64/-/mount-darwin-x64-0.10.17.tgz", "integrity": "sha512-CxC0lwH8l4CZtJGNrDtnejVBKRP888SEx4xA1nqacaCZ9FwfGoMl7RQnCVVIE3cBk9ZI/JIvD+oUqyCSK3nS+w==", "cpu": [ "x64" @@ -2421,8 +2469,8 @@ ] }, "node_modules/@relayfile/mount-linux-arm64": { - "version": "0.10.16", - "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-arm64/-/mount-linux-arm64-0.10.16.tgz", + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-arm64/-/mount-linux-arm64-0.10.17.tgz", "integrity": "sha512-4rtpGc1Eegz2xyVROj+YxSOhnxOUWWnSwuS8910MEYnu/WUu6P5XgIzca+RC76+8koFVdi5GwGJnTt0s+aeYhQ==", "cpu": [ "arm64" @@ -2434,8 +2482,8 @@ ] }, "node_modules/@relayfile/mount-linux-x64": { - "version": "0.10.16", - "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-x64/-/mount-linux-x64-0.10.16.tgz", + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-x64/-/mount-linux-x64-0.10.17.tgz", "integrity": "sha512-alL9U2l262D8EQYM5fgI6NT8GctCq6400/JYwvM7aP4Rz7ot62vPp00Grmqxjh7+jXtyykwrWdsiS6erKPZHYA==", "cpu": [ "x64" @@ -3293,9 +3341,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", - "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", "dev": true, "license": "MIT", "dependencies": { @@ -3496,6 +3544,16 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/agent-trajectories": { "version": "0.5.8", "resolved": "https://registry.npmjs.org/agent-trajectories/-/agent-trajectories-0.5.8.tgz", @@ -3578,6 +3636,16 @@ } } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "dev": true, @@ -3617,6 +3685,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -3638,6 +3713,13 @@ "node": ">=12" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3710,6 +3792,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -3840,6 +3932,13 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -3931,6 +4030,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -4734,6 +4840,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -4761,6 +4881,19 @@ "node": ">= 4" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4885,6 +5018,16 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tiktoken": { "version": "1.0.21", "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", @@ -4902,6 +5045,29 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", @@ -5542,6 +5708,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -5778,6 +5957,40 @@ } } }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -5843,6 +6056,24 @@ "node": ">=8" } }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -5936,6 +6167,16 @@ "node": ">=16.20.0" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -6848,6 +7089,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -6925,6 +7179,13 @@ "node": ">= 0.8" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -7309,6 +7570,23 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", @@ -7331,7 +7609,7 @@ }, "packages/agents": { "name": "@relayfile/agents", - "version": "0.10.16", + "version": "0.10.17", "license": "Apache-2.0", "dependencies": { "ajv": "^8.17.1", @@ -7342,7 +7620,7 @@ "@langchain/core": "^0.3.25", "@langchain/langgraph": "^0.2.31", "@openai/agents": "^0.0.13", - "@relayfile/sdk": "0.10.16", + "@relayfile/sdk": "0.10.17", "@types/node": "^20.19.43", "ai": "^4.0.20", "typescript": "^5.5.4" @@ -7353,7 +7631,7 @@ "@langchain/core": ">=0.3", "@langchain/langgraph": ">=0.2", "@openai/agents": ">=0", - "@relayfile/sdk": "^0.10.16", + "@relayfile/sdk": "^0.10.17", "ai": ">=4" }, "peerDependenciesMeta": { @@ -7766,7 +8044,7 @@ }, "packages/cli": { "name": "relayfile", - "version": "0.10.16", + "version": "0.10.17", "hasInstallScript": true, "license": "Apache-2.0", "bin": { @@ -7776,9 +8054,23 @@ "node": ">=18" } }, + "packages/client": { + "name": "@relayfile/client", + "version": "0.10.17", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^22.20.0", + "openapi-typescript": "^7.13.0", + "typescript": "^5.7.3", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "packages/core": { "name": "@relayfile/core", - "version": "0.10.16", + "version": "0.10.17", "license": "Apache-2.0", "devDependencies": { "@types/node": "^22.0.0", @@ -7791,7 +8083,7 @@ }, "packages/file-observer": { "name": "@relayfile/file-observer", - "version": "0.10.16", + "version": "0.10.17", "license": "Apache-2.0", "dependencies": { "class-variance-authority": "^0.7.0", @@ -8599,7 +8891,7 @@ }, "packages/local-mount": { "name": "@relayfile/local-mount", - "version": "0.10.16", + "version": "0.10.17", "license": "Apache-2.0", "dependencies": { "@parcel/watcher": "^2.5.6", @@ -8628,10 +8920,10 @@ }, "packages/sdk/typescript": { "name": "@relayfile/sdk", - "version": "0.10.16", + "version": "0.10.17", "license": "Apache-2.0", "dependencies": { - "@relayfile/core": "0.10.16", + "@relayfile/core": "0.10.17", "ignore": "^7.0.5", "tar": "^7.5.10" }, @@ -8643,10 +8935,10 @@ "node": ">=18" }, "optionalDependencies": { - "@relayfile/mount-darwin-arm64": "0.10.16", - "@relayfile/mount-darwin-x64": "0.10.16", - "@relayfile/mount-linux-arm64": "0.10.16", - "@relayfile/mount-linux-x64": "0.10.16" + "@relayfile/mount-darwin-arm64": "0.10.17", + "@relayfile/mount-darwin-x64": "0.10.17", + "@relayfile/mount-linux-arm64": "0.10.17", + "@relayfile/mount-linux-x64": "0.10.17" } } } diff --git a/package.json b/package.json index 3d47b2cd..6771a911 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,12 @@ "name": "relayfile", "license": "Apache-2.0", "private": true, - "version": "0.10.16", + "version": "0.10.17", "description": "relayfile — real-time filesystem for humans and agents", "workspaces": [ "packages/core", "packages/sdk/typescript", + "packages/client", "packages/cli", "packages/local-mount", "packages/file-observer", @@ -14,10 +15,10 @@ ], "scripts": { "workflow": "agent-relay run", - "build": "npm run build --workspace=packages/core && npm run build --workspace=packages/sdk/typescript && npm run build --workspace=packages/agents && npm run build --workspace=packages/local-mount && npm run build --workspace=packages/cli", - "test": "npm run test --workspace=packages/core && npm run test --workspace=packages/sdk/typescript && npm run test --workspace=packages/local-mount && npm run test --workspace=@relayfile/file-observer && npm run test:go", + "build": "npm run build --workspace=packages/core && npm run build --workspace=packages/sdk/typescript && npm run build --workspace=@relayfile/client && npm run build --workspace=packages/agents && npm run build --workspace=packages/local-mount && npm run build --workspace=packages/cli", + "test": "npm run test --workspace=packages/core && npm run test --workspace=packages/sdk/typescript && npm run test --workspace=@relayfile/client && npm run test --workspace=packages/local-mount && npm run test --workspace=@relayfile/file-observer && npm run test:go", "test:go": "go test ./...", - "typecheck": "npm run typecheck --workspace=packages/sdk/typescript && npm run typecheck --workspace=packages/agents && npm run typecheck --workspace=packages/local-mount && npm run typecheck:go", + "typecheck": "npm run typecheck --workspace=packages/sdk/typescript && npm run typecheck --workspace=@relayfile/client && npm run typecheck --workspace=packages/agents && npm run typecheck --workspace=packages/local-mount && npm run typecheck:go", "typecheck:go": "go vet ./...", "evals:compile": "node scripts/evals/compile-cases.mjs", "evals": "npm run evals:compile && node scripts/evals/run-relayfile-evals.mjs", diff --git a/packages/agents/package.json b/packages/agents/package.json index c1e1311f..ad916637 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/agents", - "version": "0.10.16", + "version": "0.10.17", "description": "Thin agent-framework adapters for Relayfile. One connect() call, ready tools, proven writeback lifecycle.", "license": "Apache-2.0", "type": "module", @@ -48,7 +48,7 @@ "@langchain/core": ">=0.3", "@langchain/langgraph": ">=0.2", "@openai/agents": ">=0", - "@relayfile/sdk": "^0.10.16", + "@relayfile/sdk": "^0.10.17", "ai": ">=4" }, "peerDependenciesMeta": { @@ -75,7 +75,7 @@ "@langchain/core": "^0.3.25", "@langchain/langgraph": "^0.2.31", "@openai/agents": "^0.0.13", - "@relayfile/sdk": "0.10.16", + "@relayfile/sdk": "0.10.17", "@types/node": "^20.19.43", "ai": "^4.0.20", "typescript": "^5.5.4" diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 8cbdadab..74c9c6eb 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -6,7 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -_No unreleased changes._ +### Fixed + +- Hardened the local control-plane daemon with serialized binding updates, socket permission guarding, explicit HTTP timeouts, and an OpenAPI-documented API-version header on `/v1/hello`. ## [0.10.16] - 2026-06-29 diff --git a/packages/cli/package.json b/packages/cli/package.json index 7783141e..5fba2f04 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "relayfile", - "version": "0.10.16", + "version": "0.10.17", "description": "CLI for relayfile — real-time filesystem for humans and agents", "bin": { "relayfile": "scripts/run.js" diff --git a/packages/cli/scripts/build-binaries.js b/packages/cli/scripts/build-binaries.js index 6546e36e..55c1c0d1 100644 --- a/packages/cli/scripts/build-binaries.js +++ b/packages/cli/scripts/build-binaries.js @@ -7,6 +7,7 @@ const path = require("path"); const repoRoot = path.resolve(__dirname, "..", "..", ".."); const packageRoot = path.resolve(__dirname, ".."); const binDir = path.join(packageRoot, "bin"); +const version = require(path.join(packageRoot, "package.json")).version; const targets = [ { goos: "darwin", goarch: "amd64" }, @@ -29,7 +30,7 @@ for (const target of targets) { [ "build", "-trimpath", - "-ldflags=-s -w", + `-ldflags=-s -w -X main.relayfileVersion=${version}`, "-o", output, "./cmd/relayfile-cli", diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md new file mode 100644 index 00000000..79e02f43 --- /dev/null +++ b/packages/client/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to this package will be documented in this file. + +## [Unreleased] + +- Added the initial typed relayfile control-plane client. + +[Unreleased]: https://github.com/AgentWorkforce/relayfile/compare/v0.10.16...HEAD diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 00000000..9779933d --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,52 @@ +{ + "name": "@relayfile/client", + "version": "0.10.17", + "description": "Typed client for the relayfile local control-plane (unix-socket HTTP/JSON), generated from the authoritative OpenAPI contract", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist" + ], + "scripts": { + "codegen": "openapi-typescript ../../openapi/relayfile-control-plane-v1.openapi.yaml -o src/generated/control-plane.ts", + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "@types/node": "^22.20.0", + "openapi-typescript": "^7.13.0", + "typescript": "^5.7.3", + "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/relayfile", + "directory": "packages/client" + }, + "license": "Apache-2.0", + "keywords": [ + "relayfile", + "control-plane", + "client", + "integrations", + "agent" + ], + "engines": { + "node": ">=18" + } +} diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts new file mode 100644 index 00000000..bd9ad989 --- /dev/null +++ b/packages/client/src/client.test.ts @@ -0,0 +1,177 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + MIN_RELAYFILE_VERSION, + RELAYFILE_API_VERSION, + RelayfileControlPlaneClient, + RelayfileControlPlaneError, + assertRelayfileVersion, + compareSemver, + defaultRelayfileSocketPath, +} from './client.js'; + +describe('assertRelayfileVersion', () => { + it('accepts the minimum and newer, tolerates v-prefix/words', () => { + expect(() => assertRelayfileVersion(MIN_RELAYFILE_VERSION)).not.toThrow(); + expect(() => assertRelayfileVersion('v0.99.0')).not.toThrow(); + expect(() => assertRelayfileVersion('relayfile 1.2.3 (abc)')).not.toThrow(); + }); + it('rejects older + unparseable', () => { + expect(() => assertRelayfileVersion('0.10.16')).toThrow(/0\.10\.17 is required/); + expect(() => assertRelayfileVersion('nope')).toThrow(/unparseable version/); + }); + it('treats missing semver components as zero', () => { + expect(compareSemver('1', '1.0.0')).toBe(0); + expect(compareSemver('1.2', '1.1.9')).toBeGreaterThan(0); + }); +}); + +describe('defaultRelayfileSocketPath', () => { + const saved = { sock: process.env.RELAYFILE_SOCK, xdg: process.env.XDG_RUNTIME_DIR }; + afterEach(() => { + if (saved.sock === undefined) delete process.env.RELAYFILE_SOCK; + else process.env.RELAYFILE_SOCK = saved.sock; + if (saved.xdg === undefined) delete process.env.XDG_RUNTIME_DIR; + else process.env.XDG_RUNTIME_DIR = saved.xdg; + }); + it('prefers RELAYFILE_SOCK, then XDG, then tmpdir', () => { + process.env.RELAYFILE_SOCK = '/run/custom.sock'; + expect(defaultRelayfileSocketPath()).toBe('/run/custom.sock'); + delete process.env.RELAYFILE_SOCK; + process.env.XDG_RUNTIME_DIR = '/run/user/1000'; + expect(defaultRelayfileSocketPath()).toBe('/run/user/1000/relayfile.sock'); + delete process.env.XDG_RUNTIME_DIR; + expect(defaultRelayfileSocketPath()).toBe(join(tmpdir(), 'relayfile.sock')); + }); +}); + +describe('RelayfileControlPlaneClient lifecycle', () => { + it('require-daemon mode (autoStart:false) fails fast with an actionable error', async () => { + const client = new RelayfileControlPlaneClient({ + socketPath: join(tmpdir(), `rf-absent-${process.pid}.sock`), + autoStart: false, + }); + await expect(client.ensureReady()).rejects.toMatchObject({ code: 'DAEMON_UNAVAILABLE' }); + await expect(client.ensureReady()).rejects.toThrow(/control-plane serve|not running/); + }); + + it('rejects a daemon whose supportedApiVersions excludes this client', async () => { + const client = new RelayfileControlPlaneClient({ socketPath: '/nope.sock', autoStart: false }); + vi.spyOn(client, 'hello').mockResolvedValue({ + daemonVersion: '0.10.17', + apiVersion: 2, + supportedApiVersions: [2], + }); + await expect(client.ensureReady()).rejects.toMatchObject({ code: 'VERSION_INCOMPATIBLE' }); + }); + + it('rejects a daemon older than the minimum version', async () => { + const client = new RelayfileControlPlaneClient({ socketPath: '/nope.sock', autoStart: false }); + vi.spyOn(client, 'hello').mockResolvedValue({ + daemonVersion: '0.10.16', + apiVersion: RELAYFILE_API_VERSION, + supportedApiVersions: [RELAYFILE_API_VERSION], + }); + await expect(client.ensureReady()).rejects.toThrow(/0\.10\.17 is required/); + }); + + it('auto-start with a missing binary fails fast with DAEMON_UNAVAILABLE (no crash)', async () => { + const client = new RelayfileControlPlaneClient({ + socketPath: join(tmpdir(), `rf-missing-bin-${process.pid}.sock`), + binary: join(tmpdir(), 'definitely-not-a-relayfile-binary-xyz'), + autoStart: true, + startTimeoutMs: 3000, + }); + await expect(client.ensureReady()).rejects.toMatchObject({ code: 'DAEMON_UNAVAILABLE' }); + }, 10000); + + it('does not cache a failed readiness probe (retries next call)', async () => { + const client = new RelayfileControlPlaneClient({ socketPath: '/nope.sock', autoStart: false }); + const hello = vi + .spyOn(client, 'hello') + .mockRejectedValueOnce(new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', 'down')) + .mockResolvedValue({ + daemonVersion: '0.10.17', + apiVersion: RELAYFILE_API_VERSION, + supportedApiVersions: [RELAYFILE_API_VERSION], + }); + await expect(client.ensureReady()).rejects.toMatchObject({ code: 'DAEMON_UNAVAILABLE' }); + await expect(client.ensureReady()).resolves.toBeUndefined(); + expect(hello).toHaveBeenCalledTimes(2); + }); +}); + +// Real-daemon contract tests — opt-in via RELAYFILE_BIN (CI builds the binary: +// `go build -o relayfile ./cmd/relayfile-cli`). Boots the daemon and drives the +// client over the socket. +const RELAYFILE_BIN = process.env.RELAYFILE_BIN?.trim(); +const describeContract = RELAYFILE_BIN ? describe : describe.skip; + +describeContract('control-plane client (real daemon)', () => { + const sock = join(tmpdir(), `rf-client-${process.pid}.sock`); + let home: string; + let daemon: ChildProcess; + const client = new RelayfileControlPlaneClient({ socketPath: sock, autoStart: false }); + + beforeAll(async () => { + home = mkdtempSync(join(tmpdir(), 'relayfile-client-')); + daemon = spawn(RELAYFILE_BIN!, ['control-plane', 'serve', '--sock', sock], { + env: { ...process.env, HOME: home, RELAYFILE_SOCK: sock }, + stdio: 'ignore', + }); + const deadline = Date.now() + 5000; + for (;;) { + try { + await client.hello(); + return; + } catch (err) { + if (Date.now() > deadline) throw err; + await new Promise((r) => setTimeout(r, 100)); + } + } + }); + + afterAll(() => { + daemon?.kill('SIGTERM'); + rmSync(sock, { force: true }); + rmSync(home, { recursive: true, force: true }); + }); + + beforeEach(() => undefined); + + it('hello() negotiates daemon version + api version', async () => { + const hello = await client.hello(); + expect(hello.supportedApiVersions).toContain(RELAYFILE_API_VERSION); + expect(() => assertRelayfileVersion(hello.daemonVersion)).not.toThrow(); + }); + + it('resolvePath() maps native -> glob and is idempotent', async () => { + expect((await client.resolvePath('github', 'owner/repo')).pathGlob).toBe('/github/repos/owner/repo/**'); + expect((await client.resolvePath('github', '/github/repos/owner/repo/**')).pathGlob).toBe( + '/github/repos/owner/repo/**' + ); + }); + + it('bind -> listBindings -> unbind round-trips on the resolved glob', async () => { + const { pathGlob } = await client.resolvePath('github', 'acme/widgets'); + await client.bind({ + provider: 'github', + resource: pathGlob, + channel: 'general', + webhookId: 'wh', + webhookToken: 'tok', + subscriptionId: 'sub', + }); + const after = await client.listBindings(); + const binding = after.find((b) => b.pathGlob === pathGlob); + expect(binding).toBeDefined(); + expect(binding!.channel).toBe('general'); + await client.unbind('github', pathGlob); + expect((await client.listBindings()).find((b) => b.pathGlob === pathGlob)).toBeUndefined(); + }); +}); diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts new file mode 100644 index 00000000..2f4ccd0d --- /dev/null +++ b/packages/client/src/client.ts @@ -0,0 +1,401 @@ +import { spawn } from 'node:child_process'; +import { request } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { components } from './generated/control-plane.js'; + +/** + * Typed client for the relayfile control-plane: HTTP/JSON over a local unix + * domain socket (`relayfile control-plane serve`). It replaces shelling out to + * the `relayfile` CLI and parsing stdout — the contract is now the socket's + * request/response shapes, version-negotiated via `/v1/hello`. + * + * Transport is Node's built-in `http.request({ socketPath })` — no dependency, + * unix-socket support is native. Request/response types are generated from the + * authoritative `openapi/relayfile-control-plane-v1.openapi.yaml`, so a field + * rename in the contract is a compile error here. + */ + +/** Control-plane API version this client speaks. Bump on a breaking wire change. */ +export const RELAYFILE_API_VERSION = 1; + +/** + * Minimum `relayfile` daemon version this client requires. The control-plane + * first shipped in 0.10.17, so anything that answers `/v1/hello` is already >= + * this — the check is belt-and-suspenders and the bump point for future + * contract changes. + */ +export const MIN_RELAYFILE_VERSION = '0.10.17'; + +const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+].*)?$/; + +/** First semver-looking token in a string (tolerates a leading `v` and surrounding words). */ +export function firstSemver(value: string): string { + for (const raw of value.split(/\s+/)) { + const token = raw.replace(/^v/, ''); + if (SEMVER_RE.test(token)) return token; + } + return ''; +} + +/** Compare two semvers by numeric core; -1 / 0 / 1. Pre-release/build metadata is ignored. */ +export function compareSemver(a: string, b: string): number { + const core = (v: string) => v.split(/[-+]/)[0]!.split('.').map((n) => Number.parseInt(n, 10) || 0); + const [a0 = 0, a1 = 0, a2 = 0] = core(a); + const [b0 = 0, b1 = 0, b2 = 0] = core(b); + return a0 - b0 || a1 - b1 || a2 - b2; +} + +/** + * Pure version gate: throws an actionable error unless `version` (a daemon + * version string) is a semver >= MIN_RELAYFILE_VERSION. Exported so the contract + * it guards is unit-tested without a running daemon. + */ +export function assertRelayfileVersion(version: string): void { + const parsed = firstSemver(version); + if (!parsed || compareSemver(parsed, MIN_RELAYFILE_VERSION) < 0) { + // Typed (not a bare Error) so callers that re-throw daemon/version failures + // by code don't silently swallow it. + throw new RelayfileControlPlaneError( + 'VERSION_INCOMPATIBLE', + `relayfile >= ${MIN_RELAYFILE_VERSION} is required; found ${ + parsed ? `"${parsed}"` : `unparseable version "${version.trim()}"` + }. Update relayfile (npm install -g relayfile@latest) or set RELAYFILE_BIN.` + ); + } +} + +/** Resolve the control-plane socket path, mirroring relayfile's Go default. */ +export function defaultRelayfileSocketPath(): string { + const explicit = process.env.RELAYFILE_SOCK?.trim(); + if (explicit) return explicit; + const xdg = process.env.XDG_RUNTIME_DIR?.trim(); + if (xdg) return join(xdg, 'relayfile.sock'); + return join(tmpdir(), 'relayfile.sock'); +} + +function relayfileBinary(): string { + return process.env.RELAYFILE_BIN?.trim() || 'relayfile'; +} + +/** Structured control-plane error carrying the daemon's typed code + HTTP status. */ +export class RelayfileControlPlaneError extends Error { + constructor( + public readonly code: string, + message: string, + public readonly status?: number + ) { + super(message); + this.name = 'RelayfileControlPlaneError'; + } +} + +// Request/response types are DERIVED from the control-plane OpenAPI contract +// (generated/control-plane.ts, from openapi/relayfile-control-plane-v1.openapi.yaml +// via `npm run codegen`). A field rename in the contract becomes a compile error +// here — the compile-time field-drift guarantee. +type Schemas = components['schemas']; + +export type HelloResponse = Schemas['HelloResponse']; +export type RelayfileBindingRecord = Schemas['Binding']; +export type ResolvePathResult = Schemas['ResolveResourcePathResponse']; +export type BindResult = Schemas['BindResponse']; +export type BindRequestBody = Schemas['BindRequest']; +export type ConnectRequestBody = Schemas['ConnectProviderRequest']; +export type ConnectResult = Schemas['ConnectProviderResponse']; +export type ProviderStatusResult = Schemas['ProviderStatus']; +export type WritebackSecretResult = Schemas['WritebackSecret']; + +export interface RelayfileClientOptions { + /** Socket to connect to. Defaults to defaultRelayfileSocketPath(). */ + socketPath?: string; + /** Binary used to auto-start the daemon. Defaults to RELAYFILE_BIN or `relayfile`. */ + binary?: string; + /** + * Auto-start `relayfile control-plane serve` when the socket is absent. + * Defaults to true unless RELAYFILE_REQUIRE_DAEMON=1 (strict, never-spawn mode). + */ + autoStart?: boolean; + /** How long to wait for an auto-started daemon to answer /v1/hello. */ + startTimeoutMs?: number; + /** Per-request timeout. A hung socket rejects instead of blocking forever. */ + requestTimeoutMs?: number; +} + +interface RequestOptions { + method: 'GET' | 'POST'; + path: string; + query?: Record; + body?: unknown; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export class RelayfileControlPlaneClient { + private readonly socketPath: string; + private readonly binary: string; + private readonly autoStart: boolean; + private readonly startTimeoutMs: number; + private readonly requestTimeoutMs: number; + private ready: Promise | undefined; + + constructor(options: RelayfileClientOptions = {}) { + this.socketPath = options.socketPath ?? defaultRelayfileSocketPath(); + this.binary = options.binary ?? relayfileBinary(); + this.autoStart = options.autoStart ?? process.env.RELAYFILE_REQUIRE_DAEMON !== '1'; + this.startTimeoutMs = options.startTimeoutMs ?? 5000; + this.requestTimeoutMs = options.requestTimeoutMs ?? 10000; + } + + /** Low-level request over the unix socket. Throws RelayfileControlPlaneError. */ + private rawRequest(opts: RequestOptions): Promise { + const query = opts.query + ? Object.entries(opts.query) + .filter(([, v]) => v != null && v !== '') + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) + .join('&') + : ''; + const path = query ? `${opts.path}?${query}` : opts.path; + const payload = opts.body == null ? undefined : JSON.stringify(opts.body); + + return new Promise((resolve, reject) => { + let settled = false; + const fail = (err: RelayfileControlPlaneError) => { + if (settled) return; + settled = true; + reject(err); + }; + const ok = (value: T) => { + if (settled) return; + settled = true; + resolve(value); + }; + + const req = request( + { + socketPath: this.socketPath, + method: opts.method, + path, + timeout: this.requestTimeoutMs, + headers: { + 'X-Relayfile-API-Version': String(RELAYFILE_API_VERSION), + Accept: 'application/json', + ...(payload + ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } + : {}), + }, + }, + (res) => { + // The response stream can emit 'error' (e.g. socket reset mid-body); + // without a listener that's an unhandled error -> crash. + res.on('error', (err) => + fail(new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', err.message)) + ); + const chunks: Buffer[] = []; + res.on('data', (c) => chunks.push(c as Buffer)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + const status = res.statusCode ?? 0; + let parsed: unknown; + try { + parsed = text ? JSON.parse(text) : undefined; + } catch { + fail( + new RelayfileControlPlaneError( + 'DAEMON_UNAVAILABLE', + `relayfile control-plane returned non-JSON (status ${status}): ${text.slice(0, 200)}`, + status + ) + ); + return; + } + if (status >= 200 && status < 300) { + ok(parsed as T); + return; + } + const err = (parsed as { error?: { code?: string; message?: string } })?.error; + fail( + new RelayfileControlPlaneError( + err?.code ?? 'DAEMON_UNAVAILABLE', + err?.message ?? `relayfile control-plane error (status ${status})`, + status + ) + ); + }); + } + ); + req.on('timeout', () => { + req.destroy( + new RelayfileControlPlaneError( + 'DAEMON_UNAVAILABLE', + `relayfile control-plane request timed out after ${this.requestTimeoutMs}ms (${opts.method} ${opts.path})` + ) + ); + }); + req.on('error', (err: NodeJS.ErrnoException) => { + if (err instanceof RelayfileControlPlaneError) { + fail(err); + return; + } + // ENOENT (no socket file) / ECONNREFUSED (nothing listening) => daemon down. + fail( + new RelayfileControlPlaneError( + 'DAEMON_UNAVAILABLE', + err.code === 'ENOENT' || err.code === 'ECONNREFUSED' + ? `relayfile control-plane is not running at ${this.socketPath} (${err.code})` + : err.message, + undefined + ) + ); + }); + if (payload) req.write(payload); + req.end(); + }); + } + + /** Connect (auto-starting the daemon if configured), version-negotiating once. */ + async ensureReady(): Promise { + if (!this.ready) { + this.ready = this.connectAndNegotiate().catch((err) => { + // Don't cache a transient failure — let the next call retry. + this.ready = undefined; + throw err; + }); + } + return this.ready; + } + + private async connectAndNegotiate(): Promise { + let hello: HelloResponse; + try { + hello = await this.hello(); + } catch (err) { + if (!(err instanceof RelayfileControlPlaneError) || err.code !== 'DAEMON_UNAVAILABLE') throw err; + if (!this.autoStart) { + throw new RelayfileControlPlaneError( + 'DAEMON_UNAVAILABLE', + `relayfile control-plane is not running at ${this.socketPath}. ` + + `Start it with \`relayfile control-plane serve\` (or unset RELAYFILE_REQUIRE_DAEMON to auto-start).` + ); + } + hello = await this.startDaemonAndConnect(); + } + if (!hello.supportedApiVersions?.includes(RELAYFILE_API_VERSION)) { + throw new RelayfileControlPlaneError( + 'VERSION_INCOMPATIBLE', + `relayfile daemon speaks API v${hello.apiVersion} (supports ${ + hello.supportedApiVersions?.join(', ') || 'none' + }); this client needs v${RELAYFILE_API_VERSION}. Upgrade relayfile (or agent-relay).` + ); + } + assertRelayfileVersion(hello.daemonVersion); + } + + private async startDaemonAndConnect(): Promise { + let child; + // A missing binary / bad RELAYFILE_BIN surfaces as an async 'error' event on + // the child (ENOENT), not a throw from spawn(). + let spawnError: Error | undefined; + try { + child = spawn(this.binary, ['control-plane', 'serve', '--sock', this.socketPath], { + detached: true, + stdio: 'ignore', + }); + child.on('error', (err) => { + spawnError = err; + }); + } catch (err) { + throw new RelayfileControlPlaneError( + 'DAEMON_UNAVAILABLE', + `failed to start relayfile control-plane (${err instanceof Error ? err.message : String(err)}). ` + + `Install relayfile or set RELAYFILE_BIN.` + ); + } + child.unref?.(); + const deadline = Date.now() + this.startTimeoutMs; + let lastErr: unknown; + while (Date.now() < deadline) { + if (spawnError) { + throw new RelayfileControlPlaneError( + 'DAEMON_UNAVAILABLE', + `failed to start relayfile control-plane (${spawnError.message}). ` + + `Install relayfile or set RELAYFILE_BIN.` + ); + } + await sleep(100); + try { + return await this.hello(); + } catch (err) { + lastErr = err; + } + } + throw new RelayfileControlPlaneError( + 'DAEMON_UNAVAILABLE', + `relayfile control-plane did not become ready within ${this.startTimeoutMs}ms ` + + `(${lastErr instanceof Error ? lastErr.message : String(lastErr)}).` + ); + } + + // ── endpoints ────────────────────────────────────────────────────────────── + + hello(): Promise { + return this.rawRequest({ method: 'GET', path: '/v1/hello' }); + } + + resolvePath(provider: string, resource: string): Promise { + return this.request({ + method: 'POST', + path: '/v1/integrations/resolve-path', + body: { provider, resource }, + }); + } + + bind(input: BindRequestBody): Promise { + return this.request({ method: 'POST', path: '/v1/integrations/bind', body: input }); + } + + async listBindings(): Promise { + const res = await this.request<{ bindings?: RelayfileBindingRecord[] }>({ + method: 'GET', + path: '/v1/integrations/bindings', + }); + return res.bindings ?? []; + } + + unbind(provider: string, resource: string): Promise { + return this.request({ method: 'POST', path: '/v1/integrations/unbind', body: { provider, resource } }); + } + + /** Returns the connection status, or null when the provider is not connected. */ + async providerStatus(provider: string): Promise { + try { + return await this.request({ + method: 'GET', + path: '/v1/integrations/provider-status', + query: { provider }, + }); + } catch (err) { + if (err instanceof RelayfileControlPlaneError && err.status === 404) return null; + throw err; + } + } + + connect(input: ConnectRequestBody): Promise { + return this.request({ method: 'POST', path: '/v1/integrations/connect', body: input }); + } + + writebackSecret(channel: string, workspace?: string): Promise { + return this.request({ + method: 'POST', + path: '/v1/integrations/writeback-secret', + body: { channel, ...(workspace ? { workspace } : {}) }, + }); + } + + /** Endpoint request that ensures the daemon is up + version-compatible first. */ + private async request(opts: RequestOptions): Promise { + await this.ensureReady(); + return this.rawRequest(opts); + } +} diff --git a/packages/client/src/generated/control-plane.ts b/packages/client/src/generated/control-plane.ts new file mode 100644 index 00000000..447bb810 --- /dev/null +++ b/packages/client/src/generated/control-plane.ts @@ -0,0 +1,602 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/v1/hello": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Negotiate control-plane API compatibility */ + get: operations["hello"]; + put?: never; + /** Negotiate control-plane API compatibility */ + post: operations["helloPost"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations/providers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List known integration providers */ + get: operations["listProviders"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations/provider-status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get connected provider status for a workspace */ + get: operations["getProviderStatus"]; + put?: never; + /** Get connected provider status for a workspace */ + post: operations["getProviderStatusPost"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations/connect": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Start or reuse a provider connection */ + post: operations["connectProvider"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations/resolve-path": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Resolve a provider-native resource to a canonical VFS glob */ + post: operations["resolveResourcePath"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations/bind": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Upsert a relay integration binding */ + post: operations["bind"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations/bindings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List relay integration bindings */ + get: operations["listBindings"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations/unbind": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Remove relay integration bindings */ + post: operations["unbind"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations/writeback-secret": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Fetch the per-channel writeback URL and signing secret */ + post: operations["getWritebackBinding"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + HelloRequest: { + /** Format: uint32 */ + apiVersion?: number; + }; + HelloResponse: { + daemonVersion: string; + /** Format: uint32 */ + apiVersion: number; + supportedApiVersions: number[]; + }; + Provider: { + id: string; + displayName?: string; + configKey?: string; + vfsRoot?: string; + deprecated?: boolean; + backend?: string; + backends?: string[]; + sources?: string[]; + authMode?: string; + categories?: string[]; + docs?: string; + }; + ProviderStatusRequest: { + provider: string; + workspace?: string; + cloudApiUrl?: string; + }; + ProviderStatus: { + provider: string; + status?: string; + lagSeconds?: number; + lastEventAt?: string; + connectionId?: string; + webhookHealthy?: boolean; + deprecated?: boolean; + }; + ConnectProviderRequest: { + provider: string; + workspace?: string; + cloudApiUrl?: string; + backend?: string; + noOpen?: boolean; + timeout?: string; + waitSync?: boolean; + }; + ConnectProviderResponse: { + provider: string; + output?: string; + }; + ResolveResourcePathRequest: { + provider: string; + resource: string; + }; + ResolveResourcePathResponse: { + provider: string; + resource: string; + pathGlob: string; + resolvedExact: boolean; + warning?: string; + }; + BindRequest: { + provider: string; + resource: string; + channel: string; + webhookId: string; + webhookToken: string; + subscriptionId?: string; + }; + BindResponse: { + binding: components["schemas"]["Binding"]; + replaced: boolean; + warning?: string; + }; + Binding: { + provider: string; + pathGlob: string; + channel: string; + webhookId: string; + webhookToken: string; + subscriptionId?: string; + createdAt?: string; + updatedAt?: string; + }; + ListBindingsResponse: { + bindings: components["schemas"]["Binding"][]; + }; + UnbindRequest: { + provider: string; + resource?: string; + }; + UnbindResponse: { + provider: string; + pathGlob: string; + removed: number; + warning?: string; + }; + WritebackSecretRequest: { + workspace?: string; + channel: string; + }; + WritebackSecret: { + url: string; + secret: string; + }; + ErrorEnvelope: { + error: { + /** @enum {string} */ + code: "INVALID_ARGUMENT" | "VERSION_INCOMPATIBLE" | "RESOURCE_UNRESOLVED" | "BINDING_NOT_FOUND" | "DAEMON_UNAVAILABLE"; + message: string; + }; + }; + }; + responses: { + /** @description Client and daemon API versions are incompatible */ + VersionIncompatible: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Requested provider or binding was not found */ + NotFound: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Provider-native resource could not be resolved */ + ResourceUnresolved: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + parameters: { + ApiVersionHeader: 1; + ApiVersionQuery: number; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + hello: { + parameters: { + query?: { + apiVersion?: components["parameters"]["ApiVersionQuery"]; + }; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Daemon version and supported API versions */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HelloResponse"]; + }; + }; + 426: components["responses"]["VersionIncompatible"]; + }; + }; + helloPost: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["HelloRequest"]; + }; + }; + responses: { + /** @description Daemon version and supported API versions */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HelloResponse"]; + }; + }; + 426: components["responses"]["VersionIncompatible"]; + }; + }; + listProviders: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Provider catalog */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + providers: components["schemas"]["Provider"][]; + }; + }; + }; + }; + }; + getProviderStatus: { + parameters: { + query: { + provider: string; + workspace?: string; + cloudApiUrl?: string; + }; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Provider status */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProviderStatus"]; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + getProviderStatusPost: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProviderStatusRequest"]; + }; + }; + responses: { + /** @description Provider status */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProviderStatus"]; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + connectProvider: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConnectProviderRequest"]; + }; + }; + responses: { + /** @description Connection command completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConnectProviderResponse"]; + }; + }; + }; + }; + resolveResourcePath: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ResolveResourcePathRequest"]; + }; + }; + responses: { + /** @description Resolved path glob */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResolveResourcePathResponse"]; + }; + }; + 422: components["responses"]["ResourceUnresolved"]; + }; + }; + bind: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BindRequest"]; + }; + }; + responses: { + /** @description Created or updated binding */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BindResponse"]; + }; + }; + }; + }; + listBindings: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Existing bindings without one-time webhook token redaction */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBindingsResponse"]; + }; + }; + }; + }; + unbind: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UnbindRequest"]; + }; + }; + responses: { + /** @description Removed binding count */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnbindResponse"]; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + getWritebackBinding: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WritebackSecretRequest"]; + }; + }; + responses: { + /** @description Writeback URL and secret */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WritebackSecret"]; + }; + }; + }; + }; +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 00000000..805c9b3a --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,25 @@ +export { + RelayfileControlPlaneClient, + RelayfileControlPlaneError, + RELAYFILE_API_VERSION, + MIN_RELAYFILE_VERSION, + assertRelayfileVersion, + compareSemver, + firstSemver, + defaultRelayfileSocketPath, +} from './client.js'; + +export type { + RelayfileClientOptions, + HelloResponse, + RelayfileBindingRecord, + ResolvePathResult, + BindResult, + BindRequestBody, + ConnectRequestBody, + ConnectResult, + ProviderStatusResult, + WritebackSecretResult, +} from './client.js'; + +export type { components, paths, operations } from './generated/control-plane.js'; diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 00000000..2809ac29 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true + }, + "include": [ + "src" + ], + "exclude": [ + "src/**/*.test.ts" + ] +} diff --git a/packages/client/vitest.config.ts b/packages/client/vitest.config.ts new file mode 100644 index 00000000..6ec74eee --- /dev/null +++ b/packages/client/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}); diff --git a/packages/core/package.json b/packages/core/package.json index 6ecacd04..78a9b569 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/core", - "version": "0.10.16", + "version": "0.10.17", "description": "Shared business logic for relayfile — file operations, ACL, queries, events, and writeback lifecycle", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/file-observer/package-lock.json b/packages/file-observer/package-lock.json index 11267c52..a3ef29b3 100644 --- a/packages/file-observer/package-lock.json +++ b/packages/file-observer/package-lock.json @@ -1,12 +1,12 @@ { "name": "@relayfile/file-observer", - "version": "0.1.0", + "version": "0.10.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@relayfile/file-observer", - "version": "0.1.0", + "version": "0.10.17", "dependencies": { "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", diff --git a/packages/file-observer/package.json b/packages/file-observer/package.json index a172657a..462224fe 100644 --- a/packages/file-observer/package.json +++ b/packages/file-observer/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/file-observer", - "version": "0.10.16", + "version": "0.10.17", "description": "RelayFile observer dashboard for browsing synced workspace files and metadata", "files": [ "src", diff --git a/packages/local-mount/package.json b/packages/local-mount/package.json index 59b160e3..e5cae1bd 100644 --- a/packages/local-mount/package.json +++ b/packages/local-mount/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/local-mount", - "version": "0.10.16", + "version": "0.10.17", "description": "Create a symlink/copy mount of a project directory with .agentignore/.agentreadonly semantics, then launch a CLI inside it and sync writable changes back on exit", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/mount-darwin-arm64/package.json b/packages/mount-darwin-arm64/package.json index 7d8c9369..6e6c7bed 100644 --- a/packages/mount-darwin-arm64/package.json +++ b/packages/mount-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/mount-darwin-arm64", - "version": "0.10.16", + "version": "0.10.17", "description": "relayfile-mount binary for darwin arm64. Installed automatically as an optional dependency of @relayfile/sdk.", "files": [ "bin" diff --git a/packages/mount-darwin-x64/package.json b/packages/mount-darwin-x64/package.json index c6fda63f..95f46532 100644 --- a/packages/mount-darwin-x64/package.json +++ b/packages/mount-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/mount-darwin-x64", - "version": "0.10.16", + "version": "0.10.17", "description": "relayfile-mount binary for darwin x64. Installed automatically as an optional dependency of @relayfile/sdk.", "files": [ "bin" diff --git a/packages/mount-linux-arm64/package.json b/packages/mount-linux-arm64/package.json index 065b9b17..6a673841 100644 --- a/packages/mount-linux-arm64/package.json +++ b/packages/mount-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/mount-linux-arm64", - "version": "0.10.16", + "version": "0.10.17", "description": "relayfile-mount binary for linux arm64. Installed automatically as an optional dependency of @relayfile/sdk.", "files": [ "bin" diff --git a/packages/mount-linux-x64/package.json b/packages/mount-linux-x64/package.json index c90d4d8e..70030d4f 100644 --- a/packages/mount-linux-x64/package.json +++ b/packages/mount-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/mount-linux-x64", - "version": "0.10.16", + "version": "0.10.17", "description": "relayfile-mount binary for linux x64. Installed automatically as an optional dependency of @relayfile/sdk.", "files": [ "bin" diff --git a/packages/sdk/typescript/package-lock.json b/packages/sdk/typescript/package-lock.json index 1a5ea3ec..5fae91e2 100644 --- a/packages/sdk/typescript/package-lock.json +++ b/packages/sdk/typescript/package-lock.json @@ -1,15 +1,15 @@ { "name": "@relayfile/sdk", - "version": "0.10.16", + "version": "0.10.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@relayfile/sdk", - "version": "0.10.16", + "version": "0.10.17", "license": "Apache-2.0", "dependencies": { - "@relayfile/core": "0.10.16", + "@relayfile/core": "0.10.17", "ignore": "^7.0.5", "tar": "^7.5.10" }, @@ -21,10 +21,10 @@ "node": ">=18" }, "optionalDependencies": { - "@relayfile/mount-darwin-arm64": "0.10.16", - "@relayfile/mount-darwin-x64": "0.10.16", - "@relayfile/mount-linux-arm64": "0.10.16", - "@relayfile/mount-linux-x64": "0.10.16" + "@relayfile/mount-darwin-arm64": "0.10.17", + "@relayfile/mount-darwin-x64": "0.10.17", + "@relayfile/mount-linux-arm64": "0.10.17", + "@relayfile/mount-linux-x64": "0.10.17" } }, "node_modules/@esbuild/aix-ppc64": { @@ -489,8 +489,8 @@ "license": "MIT" }, "node_modules/@relayfile/core": { - "version": "0.10.16", - "resolved": "https://registry.npmjs.org/@relayfile/core/-/core-0.10.16.tgz", + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@relayfile/core/-/core-0.10.17.tgz", "integrity": "sha512-rSo8ronbgN5a6okvqgZLIx688qWU0rlAl8ydBG6LbJKFuhpTI9ChNt/1dDeKlDxiB3QjgKvAPUFOnekSGfAjpQ==", "license": "Apache-2.0", "engines": { @@ -498,8 +498,8 @@ } }, "node_modules/@relayfile/mount-darwin-arm64": { - "version": "0.10.16", - "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-arm64/-/mount-darwin-arm64-0.10.16.tgz", + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-arm64/-/mount-darwin-arm64-0.10.17.tgz", "integrity": "sha512-1CzCENOqD+58ncowtNIoLpFkWuBA5VeX0csxXeKz27SfqAS1xb15CRvUIlFwY1J4uv3V6nuDFjmc73ppCuTkAg==", "cpu": [ "arm64" @@ -511,8 +511,8 @@ ] }, "node_modules/@relayfile/mount-darwin-x64": { - "version": "0.10.16", - "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-x64/-/mount-darwin-x64-0.10.16.tgz", + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-x64/-/mount-darwin-x64-0.10.17.tgz", "integrity": "sha512-CxC0lwH8l4CZtJGNrDtnejVBKRP888SEx4xA1nqacaCZ9FwfGoMl7RQnCVVIE3cBk9ZI/JIvD+oUqyCSK3nS+w==", "cpu": [ "x64" @@ -524,8 +524,8 @@ ] }, "node_modules/@relayfile/mount-linux-arm64": { - "version": "0.10.16", - "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-arm64/-/mount-linux-arm64-0.10.16.tgz", + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-arm64/-/mount-linux-arm64-0.10.17.tgz", "integrity": "sha512-4rtpGc1Eegz2xyVROj+YxSOhnxOUWWnSwuS8910MEYnu/WUu6P5XgIzca+RC76+8koFVdi5GwGJnTt0s+aeYhQ==", "cpu": [ "arm64" @@ -537,8 +537,8 @@ ] }, "node_modules/@relayfile/mount-linux-x64": { - "version": "0.10.16", - "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-x64/-/mount-linux-x64-0.10.16.tgz", + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-x64/-/mount-linux-x64-0.10.17.tgz", "integrity": "sha512-alL9U2l262D8EQYM5fgI6NT8GctCq6400/JYwvM7aP4Rz7ot62vPp00Grmqxjh7+jXtyykwrWdsiS6erKPZHYA==", "cpu": [ "x64" diff --git a/packages/sdk/typescript/package.json b/packages/sdk/typescript/package.json index 9dba7daf..b6a765c3 100644 --- a/packages/sdk/typescript/package.json +++ b/packages/sdk/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@relayfile/sdk", - "version": "0.10.16", + "version": "0.10.17", "description": "TypeScript SDK for relayfile — real-time filesystem for humans and agents", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -59,15 +59,15 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@relayfile/core": "0.10.16", + "@relayfile/core": "0.10.17", "ignore": "^7.0.5", "tar": "^7.5.10" }, "optionalDependencies": { - "@relayfile/mount-darwin-arm64": "0.10.16", - "@relayfile/mount-darwin-x64": "0.10.16", - "@relayfile/mount-linux-arm64": "0.10.16", - "@relayfile/mount-linux-x64": "0.10.16" + "@relayfile/mount-darwin-arm64": "0.10.17", + "@relayfile/mount-darwin-x64": "0.10.17", + "@relayfile/mount-linux-arm64": "0.10.17", + "@relayfile/mount-linux-x64": "0.10.17" }, "devDependencies": { "typescript": "^5.7.3", diff --git a/scripts/finalize-changelogs.mjs b/scripts/finalize-changelogs.mjs index 482069ac..27cbb6ed 100644 --- a/scripts/finalize-changelogs.mjs +++ b/scripts/finalize-changelogs.mjs @@ -22,6 +22,7 @@ const repoSlug = process.env.GITHUB_REPOSITORY || 'AgentWorkforce/relayfile'; const changelogs = [ 'packages/core/CHANGELOG.md', 'packages/sdk/typescript/CHANGELOG.md', + 'packages/client/CHANGELOG.md', 'packages/cli/CHANGELOG.md', 'packages/file-observer/CHANGELOG.md', 'packages/local-mount/CHANGELOG.md',