@relayfile/sdk 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,68 @@ TypeScript SDK for relayfile — real-time filesystem for humans and agents.
8
8
  npm install @relayfile/sdk
9
9
  ```
10
10
 
11
- ## Quick Example
11
+ ## Agent Workspace — Golden Path
12
+
13
+ The recommended way to use `@relayfile/sdk` in an agent workflow is through
14
+ `RelayfileSetup`. This handles workspace creation, Notion authorization,
15
+ environment variable generation for `relayfile-mount`, and agent invites.
16
+
17
+ ```ts
18
+ import { RelayfileSetup } from '@relayfile/sdk'
19
+
20
+ const setup = await RelayfileSetup.login({
21
+ onLoginUrl: (url) => console.log(`Sign in to Relayfile Cloud: ${url}`),
22
+ })
23
+
24
+ // Create or resume a workspace
25
+ const workspace = await setup.createWorkspace({
26
+ name: 'notion-research-room',
27
+ agentName: 'lead-agent',
28
+ })
29
+
30
+ // Connect Notion — returns a link for the human to open
31
+ const notion = await workspace.connectNotion()
32
+ if (notion.connectLink) {
33
+ console.log(`Connect Notion: ${notion.connectLink}`)
34
+ await workspace.waitForNotion({ timeoutMs: 5 * 60_000 })
35
+ }
36
+
37
+ // Env vars for relayfile-mount (local dir or cloud sandbox)
38
+ const mountEnv = workspace.mountEnv({
39
+ localDir: '/workspace/notion',
40
+ remotePath: '/notion',
41
+ })
42
+
43
+ // Secret invite payload for a trusted co-worker agent
44
+ const invite = workspace.agentInvite({
45
+ agentName: 'review-agent',
46
+ scopes: ['fs:read', 'relaycast:write'],
47
+ })
48
+ // Never log invite — it contains credential material
49
+ ```
50
+
51
+ For the full end-to-end journey, failure modes, and acceptance criteria see
52
+ [`docs/agent-workspace-golden-path.md`](../../../docs/agent-workspace-golden-path.md).
53
+
54
+ Run the demo locally:
55
+
56
+ ```bash
57
+ npm run demo:agent-workspace --workspace=packages/sdk/typescript
58
+ ```
59
+
60
+ The demo defaults to in-process mock cloud and relayfile servers, seeds a `/notion`
61
+ file, mounts it through the deterministic harness, and proves read-only invited-agent
62
+ behavior without requiring real Notion, Relaycast, or cloud credentials. For a real
63
+ deployment, use `RelayfileSetup.login()` to send the human one Cloud sign-in URL,
64
+ or use `RelayfileSetup.fromCloudTokens()` with previously persisted Cloud tokens.
65
+ You can still pass `accessToken` directly to `new RelayfileSetup()` in CI or
66
+ advanced hosts that already provide a valid Cloud bearer token. After Cloud auth,
67
+ complete the Notion OAuth flow as described in
68
+ [`docs/agent-workspace-golden-path.md`](../../../docs/agent-workspace-golden-path.md).
69
+
70
+ ---
71
+
72
+ ## Low-Level Client Example
12
73
 
13
74
  ```ts
14
75
  import { RelayFileClient } from "@relayfile/sdk";
@@ -0,0 +1,27 @@
1
+ import type { AccessTokenProvider } from "./client.js";
2
+ import type { RelayfileSetupOptions } from "./setup-types.js";
3
+ export interface RelayfileCloudTokenSet {
4
+ apiUrl?: string;
5
+ accessToken: string;
6
+ refreshToken: string;
7
+ accessTokenExpiresAt: string;
8
+ refreshTokenExpiresAt?: string;
9
+ }
10
+ export interface RelayfileCloudTokenSetupOptions extends Omit<RelayfileSetupOptions, "accessToken" | "cloudApiUrl"> {
11
+ cloudApiUrl?: string;
12
+ refreshWindowMs?: number;
13
+ onTokens?: (tokens: RelayfileCloudTokenSet) => void | Promise<void>;
14
+ }
15
+ export interface RelayfileCloudLoginOptions extends RelayfileCloudTokenSetupOptions {
16
+ redirectHost?: string;
17
+ redirectPort?: number;
18
+ redirectPath?: string;
19
+ timeoutMs?: number;
20
+ state?: string;
21
+ signal?: AbortSignal;
22
+ openBrowser?: boolean;
23
+ onLoginUrl?: (url: string) => void | Promise<void>;
24
+ successMessage?: string;
25
+ }
26
+ export declare function runRelayfileCloudLogin(options?: RelayfileCloudLoginOptions): Promise<RelayfileCloudTokenSet>;
27
+ export declare function createRelayfileCloudAccessTokenProvider(initialTokens: RelayfileCloudTokenSet, options?: RelayfileCloudTokenSetupOptions): AccessTokenProvider;
@@ -0,0 +1,290 @@
1
+ import { CloudAbortError, CloudApiError, CloudTimeoutError, MalformedCloudResponseError } from "./setup-errors.js";
2
+ import { RELAYFILE_SDK_VERSION } from "./version.js";
3
+ const DEFAULT_CLOUD_API_URL = "https://agentrelay.com/cloud";
4
+ const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
5
+ const DEFAULT_REDIRECT_HOST = "127.0.0.1";
6
+ const DEFAULT_REDIRECT_PATH = "/callback";
7
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
8
+ const DEFAULT_REFRESH_WINDOW_MS = 60_000;
9
+ export async function runRelayfileCloudLogin(options = {}) {
10
+ const cloudApiUrl = normalizeNonEmptyString(options.cloudApiUrl) ?? DEFAULT_CLOUD_API_URL;
11
+ const redirectHost = normalizeNonEmptyString(options.redirectHost) ?? DEFAULT_REDIRECT_HOST;
12
+ const redirectPort = Math.max(0, Math.floor(options.redirectPort ?? 0));
13
+ const redirectPath = normalizeRedirectPath(options.redirectPath);
14
+ const timeoutMs = Math.max(1, Math.floor(options.timeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS));
15
+ const state = normalizeNonEmptyString(options.state) ?? await createRandomState();
16
+ const http = await import("node:http");
17
+ return new Promise((resolve, reject) => {
18
+ let settled = false;
19
+ let loginUrlSent = false;
20
+ let timer;
21
+ const server = http.createServer((request, response) => {
22
+ if (!request.url) {
23
+ response.writeHead(400, { "content-type": "text/plain" });
24
+ response.end("Missing callback URL");
25
+ return;
26
+ }
27
+ const callbackUrl = new URL(request.url, `http://${redirectHost}:${readServerPort(server, redirectPort)}`);
28
+ if (callbackUrl.pathname !== redirectPath) {
29
+ response.writeHead(404, { "content-type": "text/plain" });
30
+ response.end("Not found");
31
+ return;
32
+ }
33
+ const error = callbackUrl.searchParams.get("error");
34
+ if (error) {
35
+ response.writeHead(400, { "content-type": "text/plain" });
36
+ response.end("Relayfile Cloud login failed");
37
+ finish(reject, new Error(`Relayfile Cloud login failed: ${error}`));
38
+ return;
39
+ }
40
+ const returnedState = callbackUrl.searchParams.get("state");
41
+ if (returnedState !== state) {
42
+ response.writeHead(400, { "content-type": "text/plain" });
43
+ response.end("Relayfile Cloud login state mismatch");
44
+ finish(reject, new Error("Relayfile Cloud login state mismatch"));
45
+ return;
46
+ }
47
+ let tokens;
48
+ try {
49
+ tokens = readTokenSetFromSearchParams(callbackUrl.searchParams, cloudApiUrl);
50
+ }
51
+ catch (error) {
52
+ response.writeHead(400, { "content-type": "text/plain" });
53
+ response.end("Relayfile Cloud login callback was missing token fields");
54
+ finish(reject, error);
55
+ return;
56
+ }
57
+ response.writeHead(200, { "content-type": "text/plain" });
58
+ response.end(options.successMessage ?? "Relayfile Cloud login complete. You can close this tab.");
59
+ finish(resolve, tokens);
60
+ });
61
+ const abort = () => {
62
+ finish(reject, new CloudAbortError("cloudLogin"));
63
+ };
64
+ const finish = (settle, value) => {
65
+ if (settled) {
66
+ return;
67
+ }
68
+ settled = true;
69
+ if (timer) {
70
+ clearTimeout(timer);
71
+ }
72
+ options.signal?.removeEventListener("abort", abort);
73
+ server.close();
74
+ settle(value);
75
+ };
76
+ if (options.signal?.aborted) {
77
+ finish(reject, new CloudAbortError("cloudLogin"));
78
+ return;
79
+ }
80
+ timer = setTimeout(() => {
81
+ finish(reject, new CloudTimeoutError("cloudLogin", timeoutMs));
82
+ }, timeoutMs);
83
+ options.signal?.addEventListener("abort", abort, { once: true });
84
+ server.once("error", (error) => {
85
+ finish(reject, error);
86
+ });
87
+ server.listen(redirectPort, redirectHost, () => {
88
+ const port = readServerPort(server, redirectPort);
89
+ const redirectUri = `http://${redirectHost}:${port}${redirectPath}`;
90
+ const loginUrl = buildCloudUrl(cloudApiUrl, "api/v1/cli/login");
91
+ loginUrl.searchParams.set("redirect_uri", redirectUri);
92
+ loginUrl.searchParams.set("state", state);
93
+ Promise.resolve(deliverLoginUrl(loginUrl.toString(), options.onLoginUrl))
94
+ .then(async () => {
95
+ loginUrlSent = true;
96
+ if (options.openBrowser) {
97
+ await openBrowser(loginUrl.toString());
98
+ }
99
+ })
100
+ .catch((error) => {
101
+ finish(reject, error);
102
+ });
103
+ });
104
+ server.once("close", () => {
105
+ if (!settled && !loginUrlSent) {
106
+ finish(reject, new Error("Relayfile Cloud login server closed before login URL was delivered"));
107
+ }
108
+ });
109
+ });
110
+ }
111
+ export function createRelayfileCloudAccessTokenProvider(initialTokens, options = {}) {
112
+ const cloudApiUrl = normalizeNonEmptyString(options.cloudApiUrl) ??
113
+ normalizeNonEmptyString(initialTokens.apiUrl) ??
114
+ DEFAULT_CLOUD_API_URL;
115
+ const requestTimeoutMs = Math.max(1, Math.floor(options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS));
116
+ const refreshWindowMs = Math.max(0, Math.floor(options.refreshWindowMs ?? DEFAULT_REFRESH_WINDOW_MS));
117
+ let tokens = {
118
+ ...initialTokens,
119
+ apiUrl: normalizeNonEmptyString(initialTokens.apiUrl) ?? cloudApiUrl
120
+ };
121
+ let refreshPromise;
122
+ return async () => {
123
+ if (shouldRefresh(tokens, refreshWindowMs)) {
124
+ if (!refreshPromise) {
125
+ refreshPromise = refresh();
126
+ }
127
+ try {
128
+ await refreshPromise;
129
+ }
130
+ finally {
131
+ refreshPromise = undefined;
132
+ }
133
+ }
134
+ return tokens.accessToken;
135
+ };
136
+ async function refresh() {
137
+ const response = await fetchWithTimeout(buildCloudUrl(cloudApiUrl, "api/v1/auth/token/refresh").toString(), {
138
+ method: "POST",
139
+ headers: {
140
+ "Content-Type": "application/json",
141
+ "X-Relayfile-SDK-Version": RELAYFILE_SDK_VERSION
142
+ },
143
+ body: JSON.stringify({ refreshToken: tokens.refreshToken })
144
+ }, requestTimeoutMs);
145
+ const payload = await readResponseBody(response);
146
+ if (!response.ok) {
147
+ throw new CloudApiError(response.status, payload);
148
+ }
149
+ tokens = readTokenSetFromPayload(payload, cloudApiUrl);
150
+ await options.onTokens?.({ ...tokens });
151
+ }
152
+ }
153
+ function shouldRefresh(tokens, refreshWindowMs) {
154
+ const expiresAt = Date.parse(tokens.accessTokenExpiresAt);
155
+ if (Number.isNaN(expiresAt)) {
156
+ return true;
157
+ }
158
+ return expiresAt - Date.now() <= refreshWindowMs;
159
+ }
160
+ async function fetchWithTimeout(url, init, timeoutMs) {
161
+ const controller = new AbortController();
162
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
163
+ try {
164
+ return await fetch(url, { ...init, signal: controller.signal });
165
+ }
166
+ catch (error) {
167
+ if (controller.signal.aborted) {
168
+ throw new CloudTimeoutError("refreshCloudAccessToken", timeoutMs);
169
+ }
170
+ throw error;
171
+ }
172
+ finally {
173
+ clearTimeout(timer);
174
+ }
175
+ }
176
+ function readTokenSetFromSearchParams(params, fallbackApiUrl) {
177
+ return {
178
+ apiUrl: normalizeNonEmptyString(params.get("api_url") ?? undefined) ?? fallbackApiUrl,
179
+ accessToken: requireSearchParam(params, "access_token"),
180
+ refreshToken: requireSearchParam(params, "refresh_token"),
181
+ accessTokenExpiresAt: requireSearchParam(params, "access_token_expires_at"),
182
+ refreshTokenExpiresAt: normalizeNonEmptyString(params.get("refresh_token_expires_at") ?? undefined)
183
+ };
184
+ }
185
+ function readTokenSetFromPayload(payload, fallbackApiUrl) {
186
+ return {
187
+ apiUrl: readOptionalStringField(payload, "apiUrl") ?? fallbackApiUrl,
188
+ accessToken: requireStringField(payload, "accessToken"),
189
+ refreshToken: requireStringField(payload, "refreshToken"),
190
+ accessTokenExpiresAt: requireStringField(payload, "accessTokenExpiresAt"),
191
+ refreshTokenExpiresAt: readOptionalStringField(payload, "refreshTokenExpiresAt")
192
+ };
193
+ }
194
+ function requireSearchParam(params, field) {
195
+ const value = normalizeNonEmptyString(params.get(field) ?? undefined);
196
+ if (!value) {
197
+ throw new MalformedCloudResponseError(field, Object.fromEntries(params));
198
+ }
199
+ return value;
200
+ }
201
+ async function readResponseBody(response) {
202
+ const text = await response.text();
203
+ if (text === "") {
204
+ return null;
205
+ }
206
+ const contentType = response.headers.get("content-type") ?? "";
207
+ if (contentType.includes("application/json")) {
208
+ try {
209
+ return JSON.parse(text);
210
+ }
211
+ catch {
212
+ return text;
213
+ }
214
+ }
215
+ return text;
216
+ }
217
+ function requireStringField(payload, field) {
218
+ const value = readField(payload, field);
219
+ if (typeof value !== "string" || value.trim() === "") {
220
+ throw new MalformedCloudResponseError(field, payload);
221
+ }
222
+ return value;
223
+ }
224
+ function readOptionalStringField(payload, field) {
225
+ const value = readField(payload, field);
226
+ if (value === undefined) {
227
+ return undefined;
228
+ }
229
+ if (typeof value !== "string" || value.trim() === "") {
230
+ throw new MalformedCloudResponseError(field, payload);
231
+ }
232
+ return value;
233
+ }
234
+ function readField(payload, field) {
235
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
236
+ return undefined;
237
+ }
238
+ return payload[field];
239
+ }
240
+ function buildCloudUrl(baseUrl, path) {
241
+ const base = new URL(baseUrl);
242
+ if (!base.pathname.endsWith("/")) {
243
+ base.pathname = `${base.pathname}/`;
244
+ }
245
+ return new URL(path.replace(/^\/+/, ""), base);
246
+ }
247
+ function normalizeRedirectPath(path) {
248
+ const normalized = normalizeNonEmptyString(path) ?? DEFAULT_REDIRECT_PATH;
249
+ return normalized.startsWith("/") ? normalized : `/${normalized}`;
250
+ }
251
+ function normalizeNonEmptyString(value) {
252
+ const normalized = value?.trim();
253
+ return normalized ? normalized : undefined;
254
+ }
255
+ async function createRandomState() {
256
+ const crypto = await import("node:crypto");
257
+ return crypto.randomBytes(24).toString("base64url");
258
+ }
259
+ function readServerPort(server, fallback) {
260
+ const address = server.address();
261
+ return typeof address === "object" && address !== null ? address.port : fallback;
262
+ }
263
+ async function openBrowser(url) {
264
+ const childProcess = await import("node:child_process");
265
+ const command = process.platform === "darwin"
266
+ ? "open"
267
+ : process.platform === "win32"
268
+ ? "cmd"
269
+ : "xdg-open";
270
+ const args = process.platform === "win32"
271
+ ? ["/c", "start", "", url]
272
+ : [url];
273
+ await new Promise((resolve) => {
274
+ const child = childProcess.spawn(command, args, {
275
+ detached: true,
276
+ stdio: "ignore"
277
+ });
278
+ child.once("error", () => resolve());
279
+ child.once("spawn", () => {
280
+ child.unref();
281
+ resolve();
282
+ });
283
+ });
284
+ }
285
+ function deliverLoginUrl(url, onLoginUrl) {
286
+ if (onLoginUrl) {
287
+ return onLoginUrl(url);
288
+ }
289
+ console.log(`Sign in to Relayfile Cloud: ${url}`);
290
+ }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  export { RelayFileClient, DEFAULT_RELAYFILE_BASE_URL, type AccessTokenProvider, type ConnectWebSocketOptions, type RelayFileClientOptions, type RelayFileRetryOptions, type WebSocketConnection } from "./client.js";
2
- export { RelayfileSetup, WorkspaceHandle } from "./setup.js";
2
+ export { RelayfileSetup, RELAYFILE_SDK_VERSION, WorkspaceHandle } from "./setup.js";
3
+ export { type RelayfileCloudLoginOptions, type RelayfileCloudTokenSet, type RelayfileCloudTokenSetupOptions } from "./cloud-login.js";
3
4
  export { CloudAbortError, CloudApiError, CloudTimeoutError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.js";
4
- export { WORKSPACE_INTEGRATION_PROVIDERS, type ConnectIntegrationOptions, type ConnectIntegrationResult, type CreateWorkspaceOptions, type JoinWorkspaceOptions, type RelayfileSetupOptions, type RelayfileSetupRetryOptions, type WaitForConnectionOptions, type WorkspaceInfo, type WorkspaceIntegrationProvider, type WorkspacePermissions } from "./setup-types.js";
5
+ export { WORKSPACE_INTEGRATION_PROVIDERS, type AgentWorkspaceInvite, type AgentWorkspaceInviteOptions, type ConnectIntegrationOptions, type ConnectIntegrationResult, type CreateWorkspaceOptions, type JoinWorkspaceOptions, type RelayfileSetupOptions, type RelayfileSetupRetryOptions, type WaitForConnectionOptions, type WorkspaceInfo, type WorkspaceIntegrationProvider, type WorkspaceMountEnv, type WorkspaceMountEnvOptions, type WorkspacePermissions } from "./setup-types.js";
5
6
  export { RelayFileSync, type RelayFileSyncOptions, type RelayFileSyncPong, type RelayFileSyncReconnectOptions, type RelayFileSyncSocket, type RelayFileSyncState } from "./sync.js";
6
7
  export { InvalidStateError, PayloadTooLargeError, QueueFullError, RelayFileApiError, RevisionConflictError } from "./errors.js";
7
8
  export { IntegrationProvider, computeCanonicalPath } from "./provider.js";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { RelayFileClient, DEFAULT_RELAYFILE_BASE_URL } from "./client.js";
2
- export { RelayfileSetup, WorkspaceHandle } from "./setup.js";
2
+ export { RelayfileSetup, RELAYFILE_SDK_VERSION, WorkspaceHandle } from "./setup.js";
3
3
  export { CloudAbortError, CloudApiError, CloudTimeoutError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.js";
4
4
  export { WORKSPACE_INTEGRATION_PROVIDERS } from "./setup-types.js";
5
5
  export { RelayFileSync } from "./sync.js";
@@ -0,0 +1,52 @@
1
+ import { RelayFileClient } from "./client.js";
2
+ export interface RelayfileMountHarnessConfig {
3
+ baseUrl: string;
4
+ token: string;
5
+ workspaceId: string;
6
+ remotePath: string;
7
+ localDir: string;
8
+ mode: "poll" | "fuse";
9
+ }
10
+ export interface RelayfileMountHarnessOptions {
11
+ env?: NodeJS.ProcessEnv | Record<string, string>;
12
+ pollIntervalMs?: number;
13
+ scopes?: string[];
14
+ onEvent?: (event: RelayfileMountHarnessEvent) => void;
15
+ client?: RelayFileClient;
16
+ }
17
+ export type RelayfileMountHarnessEvent = {
18
+ type: "started";
19
+ localDir: string;
20
+ remotePath: string;
21
+ workspaceId: string;
22
+ } | {
23
+ type: "sync.completed";
24
+ localDir: string;
25
+ remotePath: string;
26
+ files: number;
27
+ directories: number;
28
+ } | {
29
+ type: "permission.denied";
30
+ path: string;
31
+ scopes: string[];
32
+ } | {
33
+ type: "stopped";
34
+ localDir: string;
35
+ remotePath: string;
36
+ };
37
+ export interface RelayfileMountHarnessHandle {
38
+ readonly config: RelayfileMountHarnessConfig;
39
+ readonly localDir: string;
40
+ syncOnce(): Promise<void>;
41
+ writeLocalFile(relativePath: string, content: string): Promise<void>;
42
+ stop(): Promise<void>;
43
+ }
44
+ export declare class MountHarnessPermissionError extends Error {
45
+ readonly code = "permission_denied";
46
+ readonly path: string;
47
+ readonly scopes: string[];
48
+ constructor(targetPath: string, scopes: string[]);
49
+ }
50
+ export declare function readMountHarnessConfig(env?: NodeJS.ProcessEnv | Record<string, string>): RelayfileMountHarnessConfig;
51
+ export declare function startMountHarness(options?: RelayfileMountHarnessOptions): Promise<RelayfileMountHarnessHandle>;
52
+ export declare function runMountHarnessCli(argv?: string[], env?: NodeJS.ProcessEnv | Record<string, string>): Promise<void>;
@@ -0,0 +1,394 @@
1
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { pathToFileURL } from "node:url";
5
+ import { RelayFileApiError } from "./errors.js";
6
+ import { RelayFileClient } from "./client.js";
7
+ const DEFAULT_POLL_INTERVAL_MS = 1_000;
8
+ export class MountHarnessPermissionError extends Error {
9
+ code = "permission_denied";
10
+ path;
11
+ scopes;
12
+ constructor(targetPath, scopes) {
13
+ super(`Write denied for ${targetPath}: invited agent is read-only in the harness.`);
14
+ this.name = "MountHarnessPermissionError";
15
+ this.path = targetPath;
16
+ this.scopes = [...scopes];
17
+ }
18
+ }
19
+ export function readMountHarnessConfig(env = process.env) {
20
+ const baseUrl = readRequiredEnv(env, "RELAYFILE_BASE_URL");
21
+ const token = readRequiredEnv(env, "RELAYFILE_TOKEN");
22
+ const workspaceId = readRequiredEnv(env, "RELAYFILE_WORKSPACE");
23
+ const localDir = readRequiredEnv(env, "RELAYFILE_LOCAL_DIR");
24
+ const remotePath = normalizeRemotePath(env.RELAYFILE_REMOTE_PATH ?? "/");
25
+ const mode = env.RELAYFILE_MOUNT_MODE === "fuse" ? "fuse" : "poll";
26
+ return {
27
+ baseUrl,
28
+ token,
29
+ workspaceId,
30
+ remotePath,
31
+ localDir: path.resolve(localDir),
32
+ mode,
33
+ };
34
+ }
35
+ export async function startMountHarness(options = {}) {
36
+ const harness = new RelayfileMountHarness(options);
37
+ await harness.start();
38
+ return harness;
39
+ }
40
+ class RelayfileMountHarness {
41
+ config;
42
+ localDir;
43
+ client;
44
+ pollIntervalMs;
45
+ scopes;
46
+ onEvent;
47
+ timer;
48
+ stopped = false;
49
+ syncInFlight;
50
+ constructor(options) {
51
+ this.config = readMountHarnessConfig(options.env);
52
+ this.localDir = this.config.localDir;
53
+ this.pollIntervalMs = normalizePollIntervalMs(options.pollIntervalMs);
54
+ this.scopes = [...(options.scopes ?? [])];
55
+ this.onEvent = options.onEvent;
56
+ this.client =
57
+ options.client ??
58
+ new RelayFileClient({
59
+ baseUrl: this.config.baseUrl,
60
+ token: this.config.token,
61
+ });
62
+ }
63
+ async start() {
64
+ await mkdir(this.localDir, { recursive: true });
65
+ this.emit({
66
+ type: "started",
67
+ localDir: this.localDir,
68
+ remotePath: this.config.remotePath,
69
+ workspaceId: this.config.workspaceId,
70
+ });
71
+ await this.syncOnce();
72
+ this.scheduleNextSync();
73
+ }
74
+ async syncOnce() {
75
+ if (this.stopped) {
76
+ return;
77
+ }
78
+ if (!this.syncInFlight) {
79
+ this.syncInFlight = this.performSyncOnce().finally(() => {
80
+ this.syncInFlight = undefined;
81
+ });
82
+ }
83
+ await this.syncInFlight;
84
+ }
85
+ async writeLocalFile(relativePath, content) {
86
+ const normalizedRelativePath = normalizeRelativePath(relativePath);
87
+ const scopedTargetPath = joinRemotePath(this.config.remotePath, normalizedRelativePath);
88
+ if (!this.scopes.includes("fs:write")) {
89
+ const error = new MountHarnessPermissionError(scopedTargetPath, this.scopes);
90
+ this.emit({
91
+ type: "permission.denied",
92
+ path: scopedTargetPath,
93
+ scopes: [...this.scopes],
94
+ });
95
+ throw error;
96
+ }
97
+ const localPath = path.join(this.localDir, normalizedRelativePath);
98
+ await mkdir(path.dirname(localPath), { recursive: true });
99
+ await writeFile(localPath, content, "utf8");
100
+ }
101
+ async stop() {
102
+ if (this.stopped) {
103
+ return;
104
+ }
105
+ this.stopped = true;
106
+ if (this.timer) {
107
+ clearTimeout(this.timer);
108
+ this.timer = undefined;
109
+ }
110
+ if (this.syncInFlight) {
111
+ await this.syncInFlight;
112
+ }
113
+ this.emit({
114
+ type: "stopped",
115
+ localDir: this.localDir,
116
+ remotePath: this.config.remotePath,
117
+ });
118
+ }
119
+ async performSyncOnce() {
120
+ await mkdir(this.localDir, { recursive: true });
121
+ const desiredFiles = new Map();
122
+ const desiredDirectories = new Set([""]);
123
+ await this.collectRemoteTree(this.config.remotePath, desiredFiles, desiredDirectories);
124
+ for (const relativeDir of [...desiredDirectories].sort((left, right) => left.localeCompare(right))) {
125
+ if (relativeDir === "") {
126
+ continue;
127
+ }
128
+ await mkdir(path.join(this.localDir, relativeDir), { recursive: true });
129
+ }
130
+ for (const [relativePath, content] of [...desiredFiles.entries()].sort(([left], [right]) => left.localeCompare(right))) {
131
+ const localPath = path.join(this.localDir, relativePath);
132
+ await mkdir(path.dirname(localPath), { recursive: true });
133
+ const currentContent = await readUtf8IfExists(localPath);
134
+ if (currentContent !== content) {
135
+ await writeFile(localPath, content, "utf8");
136
+ }
137
+ }
138
+ await pruneLocalEntries(this.localDir, desiredFiles, desiredDirectories);
139
+ this.emit({
140
+ type: "sync.completed",
141
+ localDir: this.localDir,
142
+ remotePath: this.config.remotePath,
143
+ files: desiredFiles.size,
144
+ directories: desiredDirectories.size,
145
+ });
146
+ }
147
+ async collectRemoteTree(remotePath, desiredFiles, desiredDirectories) {
148
+ const tree = await this.client.listTree(this.config.workspaceId, {
149
+ path: remotePath,
150
+ depth: 1,
151
+ });
152
+ for (const entry of tree.entries) {
153
+ const relativePath = relativeRemotePath(this.config.remotePath, entry.path);
154
+ if (relativePath === "") {
155
+ continue;
156
+ }
157
+ if (entry.type === "dir") {
158
+ desiredDirectories.add(relativePath);
159
+ await this.collectRemoteTree(entry.path, desiredFiles, desiredDirectories);
160
+ continue;
161
+ }
162
+ desiredDirectories.add(path.posix.dirname(relativePath) === "." ? "" : path.posix.dirname(relativePath));
163
+ const file = await this.client.readFile(this.config.workspaceId, entry.path);
164
+ desiredFiles.set(relativePath, decodeContent(file.content, file.encoding));
165
+ }
166
+ }
167
+ scheduleNextSync() {
168
+ if (this.stopped) {
169
+ return;
170
+ }
171
+ this.timer = setTimeout(() => {
172
+ void this.syncOnce()
173
+ .catch((error) => {
174
+ console.error(`mount-harness sync failed: ${formatErrorMessage(error)}`);
175
+ })
176
+ .finally(() => {
177
+ this.scheduleNextSync();
178
+ });
179
+ }, this.pollIntervalMs);
180
+ }
181
+ emit(event) {
182
+ this.onEvent?.(event);
183
+ }
184
+ }
185
+ async function pruneLocalEntries(rootDir, desiredFiles, desiredDirectories) {
186
+ if (!(await exists(rootDir))) {
187
+ return;
188
+ }
189
+ const entries = await walkLocalTree(rootDir);
190
+ for (const entry of entries.sort((left, right) => right.relativePath.localeCompare(left.relativePath))) {
191
+ if (entry.relativePath === "") {
192
+ continue;
193
+ }
194
+ const absolutePath = path.join(rootDir, entry.relativePath);
195
+ if (entry.kind === "file") {
196
+ if (!desiredFiles.has(entry.relativePath)) {
197
+ await rm(absolutePath, { force: true });
198
+ }
199
+ continue;
200
+ }
201
+ if (!desiredDirectories.has(entry.relativePath)) {
202
+ await rm(absolutePath, { recursive: true, force: true });
203
+ }
204
+ }
205
+ }
206
+ async function walkLocalTree(rootDir, currentDir = rootDir, entries = []) {
207
+ const children = await readdir(currentDir, { withFileTypes: true });
208
+ for (const child of children) {
209
+ const absolutePath = path.join(currentDir, child.name);
210
+ const relativePath = normalizeRelativePath(path.relative(rootDir, absolutePath));
211
+ if (child.isDirectory()) {
212
+ entries.push({ relativePath, kind: "dir" });
213
+ await walkLocalTree(rootDir, absolutePath, entries);
214
+ continue;
215
+ }
216
+ entries.push({ relativePath, kind: "file" });
217
+ }
218
+ return entries;
219
+ }
220
+ async function readUtf8IfExists(filePath) {
221
+ try {
222
+ return await readFile(filePath, "utf8");
223
+ }
224
+ catch (error) {
225
+ if (error.code === "ENOENT") {
226
+ return undefined;
227
+ }
228
+ throw error;
229
+ }
230
+ }
231
+ async function exists(targetPath) {
232
+ try {
233
+ await stat(targetPath);
234
+ return true;
235
+ }
236
+ catch (error) {
237
+ if (error.code === "ENOENT") {
238
+ return false;
239
+ }
240
+ throw error;
241
+ }
242
+ }
243
+ function decodeContent(content, encoding) {
244
+ if (encoding === "base64") {
245
+ return Buffer.from(content, "base64").toString("utf8");
246
+ }
247
+ return content;
248
+ }
249
+ function joinRemotePath(rootPath, relativePath) {
250
+ return normalizeRemotePath(`${normalizeRemotePath(rootPath).replace(/\/$/, "")}/${relativePath}`.replace(/\/{2,}/g, "/"));
251
+ }
252
+ function relativeRemotePath(rootPath, targetPath) {
253
+ const normalizedRoot = normalizeRemotePath(rootPath);
254
+ const normalizedTarget = normalizeRemotePath(targetPath);
255
+ if (normalizedRoot === normalizedTarget) {
256
+ return "";
257
+ }
258
+ if (normalizedRoot === "/") {
259
+ return normalizeRelativePath(normalizedTarget.slice(1));
260
+ }
261
+ if (!normalizedTarget.startsWith(`${normalizedRoot}/`)) {
262
+ throw new Error(`Remote path ${normalizedTarget} is outside root ${normalizedRoot}.`);
263
+ }
264
+ return normalizeRelativePath(normalizedTarget.slice(normalizedRoot.length + 1));
265
+ }
266
+ function normalizeRemotePath(input) {
267
+ const trimmed = input.trim();
268
+ const normalized = path.posix.normalize(trimmed === "" ? "/" : trimmed);
269
+ return normalized.startsWith("/") ? normalized : `/${normalized}`;
270
+ }
271
+ function normalizeRelativePath(input) {
272
+ const normalized = path.posix.normalize(input.replace(/\\/g, "/"));
273
+ if (normalized === "." || normalized === "") {
274
+ return "";
275
+ }
276
+ return normalized.replace(/^\/+/, "");
277
+ }
278
+ function normalizePollIntervalMs(value) {
279
+ if (value === undefined || !Number.isFinite(value) || value <= 0) {
280
+ return DEFAULT_POLL_INTERVAL_MS;
281
+ }
282
+ return Math.floor(value);
283
+ }
284
+ function readRequiredEnv(env, key) {
285
+ const value = env[key];
286
+ if (typeof value !== "string" || value.trim() === "") {
287
+ throw new Error(`Missing required mount env: ${key}`);
288
+ }
289
+ return value.trim();
290
+ }
291
+ function formatErrorMessage(error) {
292
+ if (error instanceof Error) {
293
+ return error.message;
294
+ }
295
+ return String(error);
296
+ }
297
+ export async function runMountHarnessCli(argv = process.argv.slice(2), env = process.env) {
298
+ const options = parseCliArgs(argv);
299
+ const harness = await startMountHarness({
300
+ env,
301
+ pollIntervalMs: options.pollIntervalMs,
302
+ scopes: options.scopes,
303
+ onEvent: (event) => {
304
+ console.log(JSON.stringify(event));
305
+ },
306
+ });
307
+ if (options.once) {
308
+ await harness.stop();
309
+ return;
310
+ }
311
+ const stopAndExit = async () => {
312
+ await harness.stop();
313
+ };
314
+ const signalHandler = () => {
315
+ void stopAndExit()
316
+ .then(() => {
317
+ process.exitCode = 0;
318
+ })
319
+ .finally(() => {
320
+ process.removeListener("SIGINT", signalHandler);
321
+ process.removeListener("SIGTERM", signalHandler);
322
+ });
323
+ };
324
+ process.on("SIGINT", signalHandler);
325
+ process.on("SIGTERM", signalHandler);
326
+ await new Promise((resolve) => {
327
+ process.on("beforeExit", () => {
328
+ resolve();
329
+ });
330
+ });
331
+ }
332
+ function parseCliArgs(argv) {
333
+ const parsed = {
334
+ once: false,
335
+ };
336
+ for (let index = 0; index < argv.length; index += 1) {
337
+ const arg = argv[index];
338
+ if (arg === "--once") {
339
+ parsed.once = true;
340
+ continue;
341
+ }
342
+ if (arg === "--poll-interval-ms") {
343
+ const rawValue = argv[index + 1];
344
+ if (!rawValue) {
345
+ throw new Error("Missing value for --poll-interval-ms");
346
+ }
347
+ parsed.pollIntervalMs = Number.parseInt(rawValue, 10);
348
+ index += 1;
349
+ continue;
350
+ }
351
+ if (arg === "--scopes") {
352
+ const rawValue = argv[index + 1];
353
+ if (!rawValue) {
354
+ throw new Error("Missing value for --scopes");
355
+ }
356
+ parsed.scopes = rawValue
357
+ .split(",")
358
+ .map((scope) => scope.trim())
359
+ .filter(Boolean);
360
+ index += 1;
361
+ continue;
362
+ }
363
+ throw new Error(`Unknown argument: ${arg}`);
364
+ }
365
+ return parsed;
366
+ }
367
+ function isDirectExecution() {
368
+ const entry = process.argv[1];
369
+ if (!entry) {
370
+ return false;
371
+ }
372
+ return import.meta.url === pathToFileURL(entry).href;
373
+ }
374
+ if (isDirectExecution()) {
375
+ void runMountHarnessCli().catch((error) => {
376
+ const status = error instanceof MountHarnessPermissionError
377
+ ? {
378
+ code: error.code,
379
+ path: error.path,
380
+ scopes: error.scopes,
381
+ }
382
+ : error instanceof RelayFileApiError
383
+ ? {
384
+ code: error.code,
385
+ status: error.status,
386
+ message: error.message,
387
+ }
388
+ : {
389
+ message: formatErrorMessage(error),
390
+ };
391
+ console.error(JSON.stringify({ type: "error", ...status }));
392
+ process.exitCode = 1;
393
+ });
394
+ }
@@ -30,6 +30,7 @@ export interface WorkspaceInfo {
30
30
  workspaceId: string;
31
31
  relayfileUrl: string;
32
32
  relaycastApiKey: string;
33
+ relaycastBaseUrl?: string;
33
34
  createdAt?: string;
34
35
  name?: string;
35
36
  wsUrl?: string;
@@ -47,8 +48,38 @@ export interface ConnectIntegrationResult {
47
48
  }
48
49
  export interface WaitForConnectionOptions {
49
50
  connectionId?: string;
51
+ pollIntervalMs?: number;
52
+ /**
53
+ * @deprecated Use pollIntervalMs. This alias will be removed in a future
54
+ * minor release.
55
+ */
50
56
  intervalMs?: number;
51
57
  timeoutMs?: number;
52
58
  signal?: AbortSignal;
53
- onPoll?: (attempt: number, ready: boolean) => void;
59
+ onPoll?: (elapsed: number) => void;
60
+ }
61
+ export interface WorkspaceMountEnvOptions {
62
+ localDir?: string;
63
+ remotePath?: string;
64
+ mode?: "poll" | "fuse";
65
+ relaycastBaseUrl?: string;
66
+ }
67
+ export type WorkspaceMountEnv = Record<string, string>;
68
+ export interface AgentWorkspaceInviteOptions {
69
+ agentName?: string;
70
+ scopes?: string[];
71
+ relaycastBaseUrl?: string;
72
+ includeRelayfileToken?: boolean;
73
+ }
74
+ export interface AgentWorkspaceInvite {
75
+ workspaceId: string;
76
+ cloudApiUrl: string;
77
+ relayfileUrl: string;
78
+ relaycastApiKey: string;
79
+ relaycastBaseUrl: string;
80
+ agentName: string;
81
+ scopes: string[];
82
+ relayfileToken?: string;
83
+ createdAt?: string;
84
+ name?: string;
54
85
  }
package/dist/setup.d.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  import { RelayFileClient, type AccessTokenProvider } from "./client.js";
2
- import { type ConnectIntegrationOptions, type ConnectIntegrationResult, type CreateWorkspaceOptions, type JoinWorkspaceOptions, type RelayfileSetupOptions, type WaitForConnectionOptions, type WorkspaceInfo, type WorkspaceIntegrationProvider, type WorkspacePermissions } from "./setup-types.js";
2
+ import { type RelayfileCloudLoginOptions, type RelayfileCloudTokenSet, type RelayfileCloudTokenSetupOptions } from "./cloud-login.js";
3
+ import { type AgentWorkspaceInvite, type AgentWorkspaceInviteOptions, type ConnectIntegrationOptions, type ConnectIntegrationResult, type CreateWorkspaceOptions, type JoinWorkspaceOptions, type RelayfileSetupOptions, type WaitForConnectionOptions, type WorkspaceMountEnv, type WorkspaceMountEnvOptions, type WorkspaceInfo, type WorkspaceIntegrationProvider, type WorkspacePermissions } from "./setup-types.js";
4
+ export { RELAYFILE_SDK_VERSION } from "./version.js";
3
5
  interface JoinWorkspaceResponse {
4
6
  workspaceId?: string;
5
7
  token?: string;
6
8
  relayfileUrl?: string;
7
9
  wsUrl?: string;
8
10
  relaycastApiKey?: string;
11
+ relaycastBaseUrl?: string;
9
12
  }
13
+ type ValidatedJoinWorkspaceResponse = Required<Pick<JoinWorkspaceResponse, "workspaceId" | "token" | "relayfileUrl" | "wsUrl" | "relaycastApiKey">> & Pick<JoinWorkspaceResponse, "relaycastBaseUrl">;
10
14
  interface NormalizedJoinWorkspaceOptions {
11
15
  agentName: string;
12
16
  scopes: string[];
@@ -32,11 +36,14 @@ export declare class RelayfileSetup {
32
36
  private readonly accessToken?;
33
37
  private readonly requestTimeoutMs;
34
38
  private readonly retryOptions;
39
+ static login(options?: RelayfileCloudLoginOptions): Promise<RelayfileSetup>;
40
+ static fromCloudTokens(tokens: RelayfileCloudTokenSet, options?: RelayfileCloudTokenSetupOptions): RelayfileSetup;
35
41
  constructor(options?: RelayfileSetupOptions);
36
42
  createWorkspace(options?: CreateWorkspaceOptions): Promise<WorkspaceHandle>;
37
43
  joinWorkspace(workspaceId: string, options?: JoinWorkspaceOptions): Promise<WorkspaceHandle>;
38
- joinWorkspaceResponse(workspaceId: string, options: NormalizedJoinWorkspaceOptions): Promise<Required<JoinWorkspaceResponse>>;
44
+ joinWorkspaceResponse(workspaceId: string, options: NormalizedJoinWorkspaceOptions): Promise<ValidatedJoinWorkspaceResponse>;
39
45
  requestJson(options: CloudRequestOptions): Promise<unknown>;
46
+ getCloudApiUrl(): string;
40
47
  }
41
48
  export declare class WorkspaceHandle {
42
49
  readonly info: WorkspaceInfo;
@@ -51,14 +58,18 @@ export declare class WorkspaceHandle {
51
58
  constructor(options: WorkspaceHandleOptions);
52
59
  client(): RelayFileClient;
53
60
  connectIntegration(provider: WorkspaceIntegrationProvider, options?: ConnectIntegrationOptions): Promise<ConnectIntegrationResult>;
61
+ connectNotion(options?: Omit<ConnectIntegrationOptions, "allowedIntegrations">): Promise<ConnectIntegrationResult>;
54
62
  waitForConnection(provider: WorkspaceIntegrationProvider, options?: WaitForConnectionOptions): Promise<void>;
63
+ waitForNotion(options?: WaitForConnectionOptions): Promise<void>;
55
64
  isConnected(provider: WorkspaceIntegrationProvider, connectionId: string): Promise<boolean>;
56
65
  disconnectIntegration(provider: WorkspaceIntegrationProvider, _connectionId?: string): Promise<void>;
57
66
  getToken(): string;
67
+ mountEnv(options?: WorkspaceMountEnvOptions): WorkspaceMountEnv;
68
+ agentInvite(options?: AgentWorkspaceInviteOptions): AgentWorkspaceInvite;
58
69
  refreshToken(): Promise<void>;
59
70
  private performRefreshToken;
60
71
  private getOrRefreshToken;
61
72
  private resolveConnectionId;
62
73
  private getConnectionStatus;
74
+ private resolveRelaycastBaseUrl;
63
75
  }
64
- export {};
package/dist/setup.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import { RelayFileClient } from "./client.js";
2
+ import { createRelayfileCloudAccessTokenProvider, runRelayfileCloudLogin } from "./cloud-login.js";
2
3
  import { CloudAbortError, CloudApiError, CloudTimeoutError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, UnknownProviderError } from "./setup-errors.js";
3
4
  import { WORKSPACE_INTEGRATION_PROVIDERS } from "./setup-types.js";
5
+ import { RELAYFILE_SDK_VERSION } from "./version.js";
6
+ export { RELAYFILE_SDK_VERSION } from "./version.js";
4
7
  const DEFAULT_CLOUD_API_URL = "https://agentrelay.com/cloud";
8
+ const DEFAULT_RELAYCAST_BASE_URL = "https://api.relaycast.dev";
5
9
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
6
10
  const DEFAULT_RETRY_BASE_DELAY_MS = 500;
7
11
  const DEFAULT_RETRY_MAX_DELAY_MS = 5_000;
@@ -16,6 +20,32 @@ export class RelayfileSetup {
16
20
  accessToken;
17
21
  requestTimeoutMs;
18
22
  retryOptions;
23
+ static async login(options = {}) {
24
+ const cloudApiUrl = options.cloudApiUrl ?? DEFAULT_CLOUD_API_URL;
25
+ const tokens = await runRelayfileCloudLogin({
26
+ ...options,
27
+ cloudApiUrl
28
+ });
29
+ await options.onTokens?.({ ...tokens });
30
+ return RelayfileSetup.fromCloudTokens(tokens, {
31
+ ...options,
32
+ cloudApiUrl: tokens.apiUrl ?? cloudApiUrl
33
+ });
34
+ }
35
+ static fromCloudTokens(tokens, options = {}) {
36
+ const cloudApiUrl = options.cloudApiUrl ?? tokens.apiUrl ?? DEFAULT_CLOUD_API_URL;
37
+ return new RelayfileSetup({
38
+ ...options,
39
+ cloudApiUrl,
40
+ accessToken: createRelayfileCloudAccessTokenProvider({
41
+ ...tokens,
42
+ apiUrl: tokens.apiUrl ?? cloudApiUrl
43
+ }, {
44
+ ...options,
45
+ cloudApiUrl
46
+ })
47
+ });
48
+ }
19
49
  constructor(options = {}) {
20
50
  this.cloudApiUrl = options.cloudApiUrl ?? DEFAULT_CLOUD_API_URL;
21
51
  this.accessToken = options.accessToken;
@@ -44,6 +74,7 @@ export class RelayfileSetup {
44
74
  workspaceId: createResponse.workspaceId,
45
75
  relayfileUrl: joinResponse.relayfileUrl,
46
76
  relaycastApiKey: joinResponse.relaycastApiKey,
77
+ relaycastBaseUrl: joinResponse.relaycastBaseUrl,
47
78
  createdAt: createResponse.createdAt,
48
79
  name: createResponse.name,
49
80
  wsUrl: joinResponse.wsUrl
@@ -61,6 +92,7 @@ export class RelayfileSetup {
61
92
  workspaceId: joinResponse.workspaceId,
62
93
  relayfileUrl: joinResponse.relayfileUrl,
63
94
  relaycastApiKey: joinResponse.relaycastApiKey,
95
+ relaycastBaseUrl: joinResponse.relaycastBaseUrl,
64
96
  wsUrl: joinResponse.wsUrl
65
97
  },
66
98
  token: joinResponse.token,
@@ -85,7 +117,9 @@ export class RelayfileSetup {
85
117
  let retries = 0;
86
118
  for (;;) {
87
119
  const token = await resolveToken(options.tokenProvider ?? this.accessToken);
88
- const headers = {};
120
+ const headers = {
121
+ "X-Relayfile-SDK-Version": RELAYFILE_SDK_VERSION
122
+ };
89
123
  if (token) {
90
124
  headers.Authorization = `Bearer ${token}`;
91
125
  }
@@ -125,6 +159,9 @@ export class RelayfileSetup {
125
159
  throw new CloudApiError(response.status, payload);
126
160
  }
127
161
  }
162
+ getCloudApiUrl() {
163
+ return this.cloudApiUrl;
164
+ }
128
165
  }
129
166
  export class WorkspaceHandle {
130
167
  info;
@@ -194,16 +231,22 @@ export class WorkspaceHandle {
194
231
  connectionId
195
232
  };
196
233
  }
234
+ async connectNotion(options = {}) {
235
+ return this.connectIntegration("notion", {
236
+ ...options,
237
+ allowedIntegrations: ["notion"]
238
+ });
239
+ }
197
240
  async waitForConnection(provider, options = {}) {
198
241
  assertProvider(provider);
199
242
  const connectionId = this.resolveConnectionId(provider, options.connectionId);
200
- const intervalMs = Math.max(0, Math.floor(options.intervalMs ?? DEFAULT_WAIT_INTERVAL_MS));
243
+ const pollIntervalMs = Math.max(0, Math.floor(options.pollIntervalMs ?? options.intervalMs ?? DEFAULT_WAIT_INTERVAL_MS));
201
244
  const timeoutMs = Math.max(1, Math.floor(options.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS));
202
245
  const startedAt = Date.now();
203
- let attempt = 0;
204
246
  for (;;) {
205
247
  throwIfAborted(options.signal, "waitForConnection");
206
248
  const elapsedMs = Date.now() - startedAt;
249
+ options.onPoll?.(elapsedMs);
207
250
  if (elapsedMs >= timeoutMs) {
208
251
  throw new IntegrationConnectionTimeoutError({
209
252
  provider,
@@ -212,7 +255,6 @@ export class WorkspaceHandle {
212
255
  timeoutMs
213
256
  });
214
257
  }
215
- attempt += 1;
216
258
  const remainingMs = timeoutMs - elapsedMs;
217
259
  let ready;
218
260
  try {
@@ -232,14 +274,16 @@ export class WorkspaceHandle {
232
274
  }
233
275
  throw error;
234
276
  }
235
- options.onPoll?.(attempt, ready);
236
277
  if (ready) {
237
278
  return;
238
279
  }
239
- const sleepMs = Math.min(intervalMs, Math.max(0, timeoutMs - (Date.now() - startedAt)));
280
+ const sleepMs = Math.min(pollIntervalMs, Math.max(0, timeoutMs - (Date.now() - startedAt)));
240
281
  await sleep(sleepMs, options.signal, "waitForConnection");
241
282
  }
242
283
  }
284
+ async waitForNotion(options = {}) {
285
+ return this.waitForConnection("notion", options);
286
+ }
243
287
  async isConnected(provider, connectionId) {
244
288
  assertProvider(provider);
245
289
  return this.getConnectionStatus(provider, this.resolveConnectionId(provider, connectionId));
@@ -257,6 +301,38 @@ export class WorkspaceHandle {
257
301
  getToken() {
258
302
  return this._token;
259
303
  }
304
+ mountEnv(options = {}) {
305
+ const relaycastBaseUrl = this.resolveRelaycastBaseUrl(options.relaycastBaseUrl);
306
+ return compactStringRecord({
307
+ RELAYFILE_BASE_URL: this.info.relayfileUrl,
308
+ RELAYFILE_TOKEN: this.getToken(),
309
+ RELAYFILE_WORKSPACE: this.workspaceId,
310
+ RELAYFILE_REMOTE_PATH: options.remotePath ?? "/",
311
+ RELAYFILE_LOCAL_DIR: options.localDir,
312
+ RELAYFILE_MOUNT_MODE: options.mode,
313
+ RELAYCAST_API_KEY: this.info.relaycastApiKey,
314
+ RELAY_API_KEY: this.info.relaycastApiKey,
315
+ RELAYCAST_BASE_URL: relaycastBaseUrl,
316
+ RELAY_BASE_URL: relaycastBaseUrl
317
+ });
318
+ }
319
+ agentInvite(options = {}) {
320
+ const relaycastBaseUrl = this.resolveRelaycastBaseUrl(options.relaycastBaseUrl);
321
+ return compactObject({
322
+ workspaceId: this.workspaceId,
323
+ cloudApiUrl: this._setup.getCloudApiUrl(),
324
+ relayfileUrl: this.info.relayfileUrl,
325
+ relaycastApiKey: this.info.relaycastApiKey,
326
+ relaycastBaseUrl,
327
+ agentName: options.agentName ?? this._joinOptions.agentName,
328
+ scopes: options.scopes && options.scopes.length > 0
329
+ ? [...options.scopes]
330
+ : [...this._joinOptions.scopes],
331
+ relayfileToken: options.includeRelayfileToken === false ? undefined : this.getToken(),
332
+ createdAt: this.info.createdAt,
333
+ name: this.info.name
334
+ });
335
+ }
260
336
  async refreshToken() {
261
337
  if (!this._refreshPromise) {
262
338
  this._refreshPromise = this.performRefreshToken();
@@ -298,6 +374,11 @@ export class WorkspaceHandle {
298
374
  }));
299
375
  return response.ready;
300
376
  }
377
+ resolveRelaycastBaseUrl(override) {
378
+ return (normalizeNonEmptyString(override) ??
379
+ normalizeNonEmptyString(this.info.relaycastBaseUrl) ??
380
+ DEFAULT_RELAYCAST_BASE_URL);
381
+ }
301
382
  }
302
383
  function assertProvider(provider) {
303
384
  if (!WORKSPACE_INTEGRATION_PROVIDERS.includes(provider)) {
@@ -337,7 +418,8 @@ function validateJoinWorkspaceResponse(payload) {
337
418
  token: requireStringField(payload, "token"),
338
419
  relayfileUrl: requireStringField(payload, "relayfileUrl"),
339
420
  wsUrl: requireStringField(payload, "wsUrl"),
340
- relaycastApiKey: requireStringField(payload, "relaycastApiKey")
421
+ relaycastApiKey: requireStringField(payload, "relaycastApiKey"),
422
+ relaycastBaseUrl: readOptionalStringField(payload, "relaycastBaseUrl")
341
423
  };
342
424
  }
343
425
  function validateConnectSessionResponse(payload) {
@@ -402,6 +484,12 @@ function clonePermissions(permissions) {
402
484
  function compactObject(value) {
403
485
  return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
404
486
  }
487
+ function compactStringRecord(value) {
488
+ return Object.fromEntries(Object.entries(value).filter((entry) => {
489
+ const [, entryValue] = entry;
490
+ return entryValue !== undefined;
491
+ }));
492
+ }
405
493
  function buildCloudUrl(baseUrl, path) {
406
494
  const base = new URL(baseUrl);
407
495
  if (!base.pathname.endsWith("/")) {
package/dist/types.d.ts CHANGED
@@ -196,6 +196,7 @@ export interface SyncProviderStatus {
196
196
  failureCodes?: Record<string, number>;
197
197
  deadLetteredEnvelopes?: number;
198
198
  deadLetteredOps?: number;
199
+ webhookHealthy?: boolean;
199
200
  }
200
201
  export interface SyncStatusResponse {
201
202
  workspaceId: string;
@@ -0,0 +1 @@
1
+ export declare const RELAYFILE_SDK_VERSION = "0.6.0";
@@ -0,0 +1 @@
1
+ export const RELAYFILE_SDK_VERSION = "0.6.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/sdk",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "TypeScript SDK for relayfile — real-time filesystem for humans and agents",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,11 +12,15 @@
12
12
  "build": "tsc",
13
13
  "typecheck": "tsc --noEmit",
14
14
  "test": "vitest run",
15
+ "test:e2e": "node scripts/setup-e2e.mjs",
16
+ "agent-workspace:e2e": "node scripts/agent-workspace-golden-path-e2e.mjs",
17
+ "test:e2e:golden-path": "node scripts/agent-workspace-golden-path-e2e.mjs",
18
+ "demo:agent-workspace": "npm run build && node scripts/agent-workspace-demo.mjs",
15
19
  "setup:e2e": "node scripts/setup-e2e.mjs",
16
20
  "prepublishOnly": "npm run build"
17
21
  },
18
22
  "dependencies": {
19
- "@relayfile/core": "0.6.0"
23
+ "@relayfile/core": "0.6.2"
20
24
  },
21
25
  "devDependencies": {
22
26
  "typescript": "^5.7.3",