@relayfile/sdk 0.5.3 → 0.6.1

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,4 +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, RELAYFILE_SDK_VERSION, WorkspaceHandle } from "./setup.js";
3
+ export { type RelayfileCloudLoginOptions, type RelayfileCloudTokenSet, type RelayfileCloudTokenSetupOptions } from "./cloud-login.js";
4
+ export { CloudAbortError, CloudApiError, CloudTimeoutError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.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";
2
6
  export { RelayFileSync, type RelayFileSyncOptions, type RelayFileSyncPong, type RelayFileSyncReconnectOptions, type RelayFileSyncSocket, type RelayFileSyncState } from "./sync.js";
3
7
  export { InvalidStateError, PayloadTooLargeError, QueueFullError, RelayFileApiError, RevisionConflictError } from "./errors.js";
4
8
  export { IntegrationProvider, computeCanonicalPath } from "./provider.js";
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  export { RelayFileClient, DEFAULT_RELAYFILE_BASE_URL } from "./client.js";
2
+ export { RelayfileSetup, RELAYFILE_SDK_VERSION, WorkspaceHandle } from "./setup.js";
3
+ export { CloudAbortError, CloudApiError, CloudTimeoutError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.js";
4
+ export { WORKSPACE_INTEGRATION_PROVIDERS } from "./setup-types.js";
2
5
  export { RelayFileSync } from "./sync.js";
3
6
  export { InvalidStateError, PayloadTooLargeError, QueueFullError, RelayFileApiError, RevisionConflictError } from "./errors.js";
4
7
  // Integration providers
@@ -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>;