@relayfile/sdk 0.7.38 → 0.8.0
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/dist/index.js +7 -0
- package/dist/workspace-mount.d.ts +13 -0
- package/dist/workspace-mount.js +373 -0
- package/dist/workspace-seeder.d.ts +16 -0
- package/dist/workspace-seeder.js +480 -0
- package/package.json +13 -2
package/dist/index.js
CHANGED
|
@@ -9,3 +9,10 @@ export { InvalidStateError, PayloadTooLargeError, QueueFullError, RelayFileApiEr
|
|
|
9
9
|
export { IntegrationProvider, computeCanonicalPath } from "./provider.js";
|
|
10
10
|
export { WritebackConsumer } from "./writeback-consumer.js";
|
|
11
11
|
export * from "./integration-adapter.js";
|
|
12
|
+
// Agent workspace provisioning helpers (`seedWorkspace`, `seedAclRules`,
|
|
13
|
+
// `ensureRelayfileMount`, …) live in CLI-only modules that statically pull in
|
|
14
|
+
// `node:child_process`, `node:fs`, and `node:path`. They are intentionally
|
|
15
|
+
// excluded from the default entry so browser/edge consumers stay node-free.
|
|
16
|
+
// Import them from the explicit subpaths instead:
|
|
17
|
+
// `@relayfile/sdk/workspace-seeder`
|
|
18
|
+
// `@relayfile/sdk/workspace-mount`
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface MountConfig {
|
|
2
|
+
binaryPath?: string;
|
|
3
|
+
relayfileUrl: string;
|
|
4
|
+
workspace: string;
|
|
5
|
+
token: string;
|
|
6
|
+
mountPoint?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface MountHandle {
|
|
9
|
+
pid: number;
|
|
10
|
+
mountPoint: string;
|
|
11
|
+
stop(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export declare function ensureRelayfileMount(config: MountConfig): Promise<MountHandle>;
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { accessSync, chmodSync, constants, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
5
|
+
import https from 'node:https';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
const RELAYFILE_VERSION = '0.1.6';
|
|
9
|
+
const RELEASE_BASE_URL = 'https://github.com/AgentWorkforce/relayfile/releases/download';
|
|
10
|
+
const CHECKSUMS_FILE = 'checksums.txt';
|
|
11
|
+
const CACHE_DIR = path.join(os.homedir(), '.agent-relay', 'bin');
|
|
12
|
+
const CACHE_PATH = path.join(CACHE_DIR, 'relayfile-mount');
|
|
13
|
+
const VERSION_PATH = path.join(CACHE_DIR, 'relayfile-mount.version');
|
|
14
|
+
const SUPPORTED_TARGETS = ['darwin-arm64', 'darwin-amd64', 'linux-arm64', 'linux-amd64'].join(', ');
|
|
15
|
+
const PLATFORM_ARCH_MAP = {
|
|
16
|
+
'darwin:arm64': 'darwin-arm64',
|
|
17
|
+
'darwin:x64': 'darwin-amd64',
|
|
18
|
+
'linux:arm64': 'linux-arm64',
|
|
19
|
+
'linux:x64': 'linux-amd64',
|
|
20
|
+
};
|
|
21
|
+
function ensureCacheDir() {
|
|
22
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
function getRelayfileTarget() {
|
|
25
|
+
const target = PLATFORM_ARCH_MAP[`${os.platform()}:${os.arch()}`];
|
|
26
|
+
if (!target) {
|
|
27
|
+
throw new Error(`Unsupported platform for relayfile-mount: ${os.platform()}-${os.arch()}. Supported targets: ${SUPPORTED_TARGETS}.`);
|
|
28
|
+
}
|
|
29
|
+
return target;
|
|
30
|
+
}
|
|
31
|
+
function getReleaseAssetUrl(assetName) {
|
|
32
|
+
return `${RELEASE_BASE_URL}/v${RELAYFILE_VERSION}/${assetName}`;
|
|
33
|
+
}
|
|
34
|
+
function readCachedVersion() {
|
|
35
|
+
try {
|
|
36
|
+
return readFileSync(VERSION_PATH, 'utf8').trim() || null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function isExecutable(filePath) {
|
|
43
|
+
try {
|
|
44
|
+
accessSync(filePath, constants.X_OK);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function downloadErrorMessage(url, status) {
|
|
52
|
+
return `Download failed with status ${status} for ${url}`;
|
|
53
|
+
}
|
|
54
|
+
function downloadBinary(url, destPath, maxRedirects = 5) {
|
|
55
|
+
ensureCacheDir();
|
|
56
|
+
const attemptDownload = (currentUrl, redirectsRemaining, resolve, reject) => {
|
|
57
|
+
const request = https.get(currentUrl, (res) => {
|
|
58
|
+
const status = res.statusCode ?? 0;
|
|
59
|
+
const location = res.headers.location;
|
|
60
|
+
const isRedirect = status >= 300 && status < 400 && location;
|
|
61
|
+
if (isRedirect) {
|
|
62
|
+
if (redirectsRemaining <= 0) {
|
|
63
|
+
res.resume();
|
|
64
|
+
reject(new Error('Too many redirects while downloading relayfile-mount'));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const nextUrl = new URL(location, currentUrl).toString();
|
|
68
|
+
res.resume();
|
|
69
|
+
attemptDownload(nextUrl, redirectsRemaining - 1, resolve, reject);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (status !== 200) {
|
|
73
|
+
res.resume();
|
|
74
|
+
reject(new Error(downloadErrorMessage(currentUrl, status)));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const fileStream = createWriteStream(destPath, { mode: 0o755 });
|
|
78
|
+
res.pipe(fileStream);
|
|
79
|
+
fileStream.on('finish', () => {
|
|
80
|
+
fileStream.close(() => resolve());
|
|
81
|
+
});
|
|
82
|
+
fileStream.on('error', (error) => reject(error instanceof Error ? error : new Error(String(error))));
|
|
83
|
+
res.on('error', (error) => reject(error instanceof Error ? error : new Error(String(error))));
|
|
84
|
+
});
|
|
85
|
+
request.on('error', (error) => reject(error instanceof Error ? error : new Error(String(error))));
|
|
86
|
+
};
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
attemptDownload(url, maxRedirects, resolve, reject);
|
|
89
|
+
}).catch((error) => {
|
|
90
|
+
try {
|
|
91
|
+
rmSync(destPath, { force: true });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Ignore cleanup failures.
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function downloadText(url, maxRedirects = 5) {
|
|
100
|
+
const fetchWithRedirects = (currentUrl, redirectsRemaining, resolve, reject) => {
|
|
101
|
+
const request = https.get(currentUrl, (res) => {
|
|
102
|
+
const status = res.statusCode ?? 0;
|
|
103
|
+
const location = res.headers.location;
|
|
104
|
+
const isRedirect = status >= 300 && status < 400 && location;
|
|
105
|
+
if (isRedirect) {
|
|
106
|
+
if (redirectsRemaining <= 0) {
|
|
107
|
+
res.resume();
|
|
108
|
+
reject(new Error('Too many redirects while downloading relayfile checksums'));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const nextUrl = new URL(location, currentUrl).toString();
|
|
112
|
+
res.resume();
|
|
113
|
+
fetchWithRedirects(nextUrl, redirectsRemaining - 1, resolve, reject);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (status !== 200) {
|
|
117
|
+
res.resume();
|
|
118
|
+
reject(new Error(downloadErrorMessage(currentUrl, status)));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const chunks = [];
|
|
122
|
+
res.on('data', (chunk) => {
|
|
123
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
124
|
+
});
|
|
125
|
+
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
126
|
+
res.on('error', (error) => reject(error instanceof Error ? error : new Error(String(error))));
|
|
127
|
+
});
|
|
128
|
+
request.on('error', (error) => reject(error instanceof Error ? error : new Error(String(error))));
|
|
129
|
+
};
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
fetchWithRedirects(url, maxRedirects, resolve, reject);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function getExpectedChecksum(checksumContent, binaryName) {
|
|
135
|
+
for (const line of checksumContent.split('\n')) {
|
|
136
|
+
const trimmed = line.trim();
|
|
137
|
+
if (!trimmed) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
|
|
141
|
+
if (!match) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const entryName = path.basename(match[2].trim());
|
|
145
|
+
if (entryName === binaryName) {
|
|
146
|
+
return match[1].toLowerCase();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
throw new Error(`No checksum entry found for ${binaryName}`);
|
|
150
|
+
}
|
|
151
|
+
async function verifyChecksum(filePath, binaryName) {
|
|
152
|
+
const checksumUrl = getReleaseAssetUrl(CHECKSUMS_FILE);
|
|
153
|
+
const checksumContent = await downloadText(checksumUrl);
|
|
154
|
+
const expectedHash = getExpectedChecksum(checksumContent, binaryName);
|
|
155
|
+
const actualHash = createHash('sha256').update(readFileSync(filePath)).digest('hex');
|
|
156
|
+
if (actualHash !== expectedHash) {
|
|
157
|
+
throw new Error(`Checksum mismatch for ${binaryName}: expected ${expectedHash}, got ${actualHash}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function resignBinaryForMacOS(binaryPath) {
|
|
161
|
+
if (os.platform() !== 'darwin') {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
// Pass the binary path as a separate argv entry so shell metacharacters
|
|
166
|
+
// in the cache path cannot break or hijack the codesign invocation.
|
|
167
|
+
execFileSync('codesign', ['--force', '--sign', '-', binaryPath], { stdio: 'pipe' });
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Ignore best-effort re-sign failures.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function ensureRelayfileMountBinary(binaryPath) {
|
|
174
|
+
if (binaryPath) {
|
|
175
|
+
return binaryPath;
|
|
176
|
+
}
|
|
177
|
+
if (process.env.RELAYFILE_ROOT) {
|
|
178
|
+
return path.join(process.env.RELAYFILE_ROOT, 'bin', 'relayfile-mount');
|
|
179
|
+
}
|
|
180
|
+
const target = getRelayfileTarget();
|
|
181
|
+
const binaryName = `relayfile-mount-${target}`;
|
|
182
|
+
const downloadUrl = getReleaseAssetUrl(binaryName);
|
|
183
|
+
ensureCacheDir();
|
|
184
|
+
if (existsSync(CACHE_PATH) && readCachedVersion() === RELAYFILE_VERSION) {
|
|
185
|
+
if (!isExecutable(CACHE_PATH)) {
|
|
186
|
+
chmodSync(CACHE_PATH, 0o755);
|
|
187
|
+
}
|
|
188
|
+
return CACHE_PATH;
|
|
189
|
+
}
|
|
190
|
+
const tempPath = path.join(CACHE_DIR, `relayfile-mount.${process.pid}.${Date.now()}.download`);
|
|
191
|
+
try {
|
|
192
|
+
await downloadBinary(downloadUrl, tempPath);
|
|
193
|
+
await verifyChecksum(tempPath, binaryName);
|
|
194
|
+
chmodSync(tempPath, 0o755);
|
|
195
|
+
renameSync(tempPath, CACHE_PATH);
|
|
196
|
+
chmodSync(CACHE_PATH, 0o755);
|
|
197
|
+
resignBinaryForMacOS(CACHE_PATH);
|
|
198
|
+
writeFileSync(VERSION_PATH, `${RELAYFILE_VERSION}\n`, 'utf8');
|
|
199
|
+
return CACHE_PATH;
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
try {
|
|
203
|
+
rmSync(tempPath, { force: true });
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// Ignore cleanup failures.
|
|
207
|
+
}
|
|
208
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
209
|
+
throw new Error(`Failed to install relayfile-mount from ${downloadUrl}: ${message}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
|
|
213
|
+
async function runCommandCapture(command, args, env, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS) {
|
|
214
|
+
return await new Promise((resolve, reject) => {
|
|
215
|
+
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
|
|
216
|
+
let output = '';
|
|
217
|
+
let settled = false;
|
|
218
|
+
const settle = (fn) => {
|
|
219
|
+
if (settled) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
settled = true;
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
fn();
|
|
225
|
+
};
|
|
226
|
+
const timer = setTimeout(() => {
|
|
227
|
+
// Best-effort terminate the stalled child so we don't leak the process.
|
|
228
|
+
try {
|
|
229
|
+
proc.kill('SIGTERM');
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// Ignore kill failures; the timeout error below is what matters.
|
|
233
|
+
}
|
|
234
|
+
settle(() => reject(new Error(`command timed out after ${timeoutMs}ms`)));
|
|
235
|
+
}, timeoutMs);
|
|
236
|
+
proc.stdout.setEncoding('utf8');
|
|
237
|
+
proc.stderr.setEncoding('utf8');
|
|
238
|
+
proc.stdout.on('data', (chunk) => {
|
|
239
|
+
output += chunk;
|
|
240
|
+
});
|
|
241
|
+
proc.stderr.on('data', (chunk) => {
|
|
242
|
+
output += chunk;
|
|
243
|
+
});
|
|
244
|
+
proc.on('error', (error) => {
|
|
245
|
+
settle(() => reject(error));
|
|
246
|
+
});
|
|
247
|
+
proc.on('close', (code, signal) => {
|
|
248
|
+
if (code === 0) {
|
|
249
|
+
settle(() => resolve(output));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const reason = signal ? `signal ${signal}` : `exit code ${typeof code === 'number' ? code : 'unknown'}`;
|
|
253
|
+
const detail = output.trim();
|
|
254
|
+
settle(() => reject(new Error(detail || `command failed with ${reason}`)));
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
function ensureProcessRunning(processRef) {
|
|
259
|
+
return processRef.exitCode === null && !processRef.killed;
|
|
260
|
+
}
|
|
261
|
+
async function stopMountProcess(processRef) {
|
|
262
|
+
if (processRef.exitCode !== null || !processRef.pid) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
processRef.kill('SIGTERM');
|
|
266
|
+
await new Promise((resolve) => {
|
|
267
|
+
const timeout = setTimeout(() => {
|
|
268
|
+
if (processRef.exitCode === null && processRef.pid) {
|
|
269
|
+
processRef.kill('SIGKILL');
|
|
270
|
+
}
|
|
271
|
+
resolve();
|
|
272
|
+
}, 1200);
|
|
273
|
+
processRef.once('exit', () => {
|
|
274
|
+
clearTimeout(timeout);
|
|
275
|
+
resolve();
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
export async function ensureRelayfileMount(config) {
|
|
280
|
+
const binaryPath = await ensureRelayfileMountBinary(config.binaryPath);
|
|
281
|
+
if (!existsSync(binaryPath)) {
|
|
282
|
+
throw new Error(`missing relayfile mount binary: ${binaryPath}`);
|
|
283
|
+
}
|
|
284
|
+
// Track whether we created the mountPoint ourselves. We must only `rm` the
|
|
285
|
+
// directory on shutdown/failure when this function owns it — never when a
|
|
286
|
+
// caller passed their own path, since that could destroy unrelated user data.
|
|
287
|
+
let mountPoint;
|
|
288
|
+
let ownsMountPoint;
|
|
289
|
+
if (config.mountPoint) {
|
|
290
|
+
mountPoint = config.mountPoint;
|
|
291
|
+
ownsMountPoint = false;
|
|
292
|
+
mkdirSync(mountPoint, { recursive: true });
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
mountPoint = await mkdtemp(path.join(os.tmpdir(), `relayfile-mount-${config.workspace}-`));
|
|
296
|
+
ownsMountPoint = true;
|
|
297
|
+
}
|
|
298
|
+
const cleanupMountPoint = async () => {
|
|
299
|
+
if (!ownsMountPoint) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
await rm(mountPoint, { recursive: true, force: true }).catch(() => undefined);
|
|
303
|
+
};
|
|
304
|
+
const mountBaseArgs = [
|
|
305
|
+
'--base-url',
|
|
306
|
+
config.relayfileUrl,
|
|
307
|
+
'--workspace',
|
|
308
|
+
config.workspace,
|
|
309
|
+
'--local-dir',
|
|
310
|
+
mountPoint,
|
|
311
|
+
];
|
|
312
|
+
const onceArgs = [...mountBaseArgs, '--once'];
|
|
313
|
+
const mountEnv = {
|
|
314
|
+
...process.env,
|
|
315
|
+
RELAYFILE_TOKEN: config.token,
|
|
316
|
+
};
|
|
317
|
+
let mountProc;
|
|
318
|
+
let startupPhase = 'initial workspace sync';
|
|
319
|
+
try {
|
|
320
|
+
await runCommandCapture(binaryPath, onceArgs, mountEnv);
|
|
321
|
+
startupPhase = 'mount process startup';
|
|
322
|
+
const startedMountProc = spawn(binaryPath, mountBaseArgs, {
|
|
323
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
324
|
+
env: mountEnv,
|
|
325
|
+
});
|
|
326
|
+
mountProc = startedMountProc;
|
|
327
|
+
await new Promise((resolve, reject) => {
|
|
328
|
+
const timer = setTimeout(() => resolve(), 600);
|
|
329
|
+
startedMountProc.on('error', (spawnError) => {
|
|
330
|
+
clearTimeout(timer);
|
|
331
|
+
reject(spawnError);
|
|
332
|
+
});
|
|
333
|
+
startedMountProc.on('spawn', () => {
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
resolve();
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
if (!ensureProcessRunning(startedMountProc) || typeof startedMountProc.pid !== 'number') {
|
|
339
|
+
await stopMountProcess(startedMountProc).catch(() => undefined);
|
|
340
|
+
throw new Error(`mount process for workspace ${config.workspace} exited before continuing`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
if (mountProc) {
|
|
345
|
+
await stopMountProcess(mountProc).catch(() => undefined);
|
|
346
|
+
}
|
|
347
|
+
await cleanupMountPoint();
|
|
348
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
349
|
+
throw new Error(`${startupPhase} failed for ${config.workspace}: ${message}`);
|
|
350
|
+
}
|
|
351
|
+
if (!mountProc || typeof mountProc.pid !== 'number') {
|
|
352
|
+
await cleanupMountPoint();
|
|
353
|
+
throw new Error(`mount process startup failed for ${config.workspace}: missing process id`);
|
|
354
|
+
}
|
|
355
|
+
let stopPromise;
|
|
356
|
+
const startedMountProc = mountProc;
|
|
357
|
+
return {
|
|
358
|
+
pid: mountProc.pid,
|
|
359
|
+
mountPoint,
|
|
360
|
+
async stop() {
|
|
361
|
+
if (!stopPromise) {
|
|
362
|
+
// Memoize the in-flight shutdown so concurrent callers all await the
|
|
363
|
+
// same termination + cleanup sequence instead of returning early
|
|
364
|
+
// before stopMountProcess and cleanupMountPoint have settled.
|
|
365
|
+
stopPromise = (async () => {
|
|
366
|
+
await stopMountProcess(startedMountProc).catch(() => undefined);
|
|
367
|
+
await cleanupMountPoint();
|
|
368
|
+
})();
|
|
369
|
+
}
|
|
370
|
+
await stopPromise;
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface WorkflowAclAgent {
|
|
2
|
+
name: string;
|
|
3
|
+
acl: Record<string, string[]>;
|
|
4
|
+
}
|
|
5
|
+
interface SeedWorkflowAclsOptions {
|
|
6
|
+
relayfileUrl: string;
|
|
7
|
+
adminToken: string;
|
|
8
|
+
workspace: string;
|
|
9
|
+
agents: WorkflowAclAgent[];
|
|
10
|
+
}
|
|
11
|
+
export declare function createWorkspaceIfNeeded(baseUrl: string, token: string, workspaceId: string): Promise<void>;
|
|
12
|
+
export declare function seedAclRules(baseUrl: string, token: string, workspaceId: string, aclRules: Record<string, string[]>): Promise<void>;
|
|
13
|
+
export declare function seedWorkspace(baseUrl: string, token: string, workspaceId: string, projectDir: string, excludeDirs: string[]): Promise<number>;
|
|
14
|
+
export declare function seedWorkflowAcls({ relayfileUrl, adminToken, workspace, agents, }: SeedWorkflowAclsOptions): Promise<void>;
|
|
15
|
+
export declare function seedWorkspaceTar(baseUrl: string, token: string, workspaceId: string, projectDir: string, excludeDirs: string[]): Promise<number>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { RelayFileClient } from './client.js';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import * as tar from 'tar';
|
|
6
|
+
const DEFAULT_EXCLUDED_DIRS = ['.relay', '.git', 'node_modules'];
|
|
7
|
+
const DEFAULT_EXCLUDED_FILES = new Set(['.relayfile-mount-state.json']);
|
|
8
|
+
const BATCH_SIZE = 50;
|
|
9
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
|
10
|
+
function normalizeBaseUrl(baseUrl) {
|
|
11
|
+
const url = String(baseUrl ?? '').trim();
|
|
12
|
+
let end = url.length;
|
|
13
|
+
while (end > 0 && url.charCodeAt(end - 1) === 0x2f) {
|
|
14
|
+
end--;
|
|
15
|
+
}
|
|
16
|
+
return end === url.length ? url : url.slice(0, end);
|
|
17
|
+
}
|
|
18
|
+
function normalizeWorkspaceId(workspaceId) {
|
|
19
|
+
const value = String(workspaceId ?? '').trim();
|
|
20
|
+
if (!value) {
|
|
21
|
+
throw new Error('workspaceId is required');
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function normalizeExcludeDirs(excludeDirs) {
|
|
26
|
+
const result = new Set();
|
|
27
|
+
for (const dir of excludeDirs) {
|
|
28
|
+
const normalized = String(dir ?? '')
|
|
29
|
+
.trim()
|
|
30
|
+
.replace(/^[/\\]+|[/\\]+$/g, '');
|
|
31
|
+
if (!normalized) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
result.add(normalized);
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
function normalizeAclDirectory(dirPath) {
|
|
39
|
+
const normalized = String(dirPath ?? '')
|
|
40
|
+
.trim()
|
|
41
|
+
.replace(/\\/gu, '/')
|
|
42
|
+
.replace(/\/+$/u, '');
|
|
43
|
+
if (!normalized || normalized === '/') {
|
|
44
|
+
return '/';
|
|
45
|
+
}
|
|
46
|
+
return normalized.startsWith('/') ? normalized : `/${normalized}`;
|
|
47
|
+
}
|
|
48
|
+
function isReviewerAgent(agentName) {
|
|
49
|
+
return /reviewer/iu.test(String(agentName ?? '').trim());
|
|
50
|
+
}
|
|
51
|
+
function createClient(baseUrl, token) {
|
|
52
|
+
return new RelayFileClient({
|
|
53
|
+
baseUrl: normalizeBaseUrl(baseUrl),
|
|
54
|
+
token,
|
|
55
|
+
retry: { maxRetries: 0 },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function isUtf8(raw) {
|
|
59
|
+
try {
|
|
60
|
+
utf8Decoder.decode(raw);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function buildSeedFilePayload(filePath, rootDir) {
|
|
68
|
+
const relative = path.relative(rootDir, filePath).split(path.sep).join('/');
|
|
69
|
+
const raw = fs.readFileSync(filePath);
|
|
70
|
+
if (isUtf8(raw)) {
|
|
71
|
+
return { path: `/${relative}`, content: raw.toString('utf8'), encoding: 'utf-8' };
|
|
72
|
+
}
|
|
73
|
+
return { path: `/${relative}`, content: raw.toString('base64'), encoding: 'base64' };
|
|
74
|
+
}
|
|
75
|
+
function collectSeedPaths(rootDir, currentRelative, excludeDirs, output, visited = new Set()) {
|
|
76
|
+
const absoluteDir = path.join(rootDir, currentRelative);
|
|
77
|
+
let realDir;
|
|
78
|
+
try {
|
|
79
|
+
realDir = fs.realpathSync(absoluteDir);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (visited.has(realDir)) {
|
|
85
|
+
// Cycle guard: a symlinked directory pointed back to an ancestor.
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
visited.add(realDir);
|
|
89
|
+
const entries = fs.readdirSync(absoluteDir, { withFileTypes: true });
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (excludeDirs.has(entry.name)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (DEFAULT_EXCLUDED_FILES.has(entry.name)) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const nextRelative = currentRelative ? `${currentRelative}/${entry.name}` : entry.name;
|
|
98
|
+
const absolutePath = path.join(rootDir, nextRelative);
|
|
99
|
+
if (excludeDirs.has(nextRelative)) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (entry.isDirectory()) {
|
|
103
|
+
collectSeedPaths(rootDir, nextRelative, excludeDirs, output, visited);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (entry.isFile()) {
|
|
107
|
+
output.push(absolutePath);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (entry.isSymbolicLink()) {
|
|
111
|
+
try {
|
|
112
|
+
const resolved = fs.realpathSync(absolutePath);
|
|
113
|
+
if (!resolved.startsWith(rootDir + path.sep) && resolved !== rootDir) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const stat = fs.statSync(resolved);
|
|
117
|
+
if (stat.isDirectory()) {
|
|
118
|
+
collectSeedPaths(rootDir, nextRelative, excludeDirs, output, visited);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (stat.isFile()) {
|
|
122
|
+
output.push(absolutePath);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Ignore symlinks that cannot be resolved.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function parseBulkWriteResponse(payload) {
|
|
132
|
+
if (!payload || typeof payload !== 'object') {
|
|
133
|
+
return { written: 0, errorCount: 0, errors: [] };
|
|
134
|
+
}
|
|
135
|
+
const parsed = payload;
|
|
136
|
+
return {
|
|
137
|
+
written: typeof parsed.written === 'number' ? parsed.written : 0,
|
|
138
|
+
errorCount: typeof parsed.errorCount === 'number' ? parsed.errorCount : 0,
|
|
139
|
+
errors: parsed.errors ?? [],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
async function postBulkWrite(baseUrl, token, workspaceId, files, correlationId) {
|
|
143
|
+
const response = await fetch(`${normalizeBaseUrl(baseUrl)}/v1/workspaces/${encodeURIComponent(workspaceId)}/fs/bulk`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: {
|
|
146
|
+
Authorization: `Bearer ${token}`,
|
|
147
|
+
'Content-Type': 'application/json',
|
|
148
|
+
'X-Correlation-Id': correlationId,
|
|
149
|
+
},
|
|
150
|
+
body: JSON.stringify({ files }),
|
|
151
|
+
});
|
|
152
|
+
const body = await response.text();
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
throw new Error(`failed to seed workspace ${workspaceId}: HTTP ${response.status} ${body}`.trim());
|
|
155
|
+
}
|
|
156
|
+
if (!body) {
|
|
157
|
+
return { written: files.length, errorCount: 0, errors: [] };
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
return parseBulkWriteResponse(JSON.parse(body));
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return { written: files.length, errorCount: 0, errors: [] };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function writeBulkWrite(baseUrl, token, workspaceId, files, correlationId) {
|
|
167
|
+
const client = createClient(baseUrl, token);
|
|
168
|
+
try {
|
|
169
|
+
const response = await client.bulkWrite({
|
|
170
|
+
workspaceId,
|
|
171
|
+
files,
|
|
172
|
+
correlationId,
|
|
173
|
+
});
|
|
174
|
+
return parseBulkWriteResponse(response);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (typeof error.status === 'number') {
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return postBulkWrite(baseUrl, token, workspaceId, files, correlationId);
|
|
182
|
+
}
|
|
183
|
+
export async function createWorkspaceIfNeeded(baseUrl, token, workspaceId) {
|
|
184
|
+
const workspace = normalizeWorkspaceId(workspaceId);
|
|
185
|
+
const client = createClient(baseUrl, token);
|
|
186
|
+
const maybeCreateWorkspace = client;
|
|
187
|
+
if (typeof maybeCreateWorkspace.createWorkspace === 'function') {
|
|
188
|
+
for (const arg of [workspace, { id: workspace }, { workspaceId: workspace }, { name: workspace }]) {
|
|
189
|
+
try {
|
|
190
|
+
await maybeCreateWorkspace.createWorkspace(arg);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// Continue to the next overload candidate, then fallback to HTTP.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const endpoint = `${normalizeBaseUrl(baseUrl)}/v1/workspaces`;
|
|
199
|
+
const bodyCandidates = [
|
|
200
|
+
{ name: workspace },
|
|
201
|
+
{ workspace: workspace },
|
|
202
|
+
{ workspaceId: workspace },
|
|
203
|
+
{ id: workspace },
|
|
204
|
+
];
|
|
205
|
+
let lastFailure = null;
|
|
206
|
+
for (const body of bodyCandidates) {
|
|
207
|
+
try {
|
|
208
|
+
const response = await fetch(endpoint, {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
headers: {
|
|
211
|
+
Authorization: `Bearer ${token}`,
|
|
212
|
+
'Content-Type': 'application/json',
|
|
213
|
+
'X-Correlation-Id': `create-workspace-${Date.now()}`,
|
|
214
|
+
},
|
|
215
|
+
body: JSON.stringify(body),
|
|
216
|
+
});
|
|
217
|
+
if (response.status === 200 ||
|
|
218
|
+
response.status === 201 ||
|
|
219
|
+
response.status === 204 ||
|
|
220
|
+
response.status === 409) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const responseBody = await response.text().catch(() => '');
|
|
224
|
+
lastFailure = `HTTP ${response.status} ${responseBody}`.trim();
|
|
225
|
+
if (response.status < 500 && response.status !== 409) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
lastFailure = String(error);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (lastFailure) {
|
|
234
|
+
throw new Error(`Failed to create workspace ${workspace}: ${lastFailure}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
export async function seedAclRules(baseUrl, token, workspaceId, aclRules) {
|
|
238
|
+
const workspace = normalizeWorkspaceId(workspaceId);
|
|
239
|
+
const files = Object.entries(aclRules).map(([dirPath, rules]) => {
|
|
240
|
+
const normalizedDir = String(dirPath ?? '')
|
|
241
|
+
.trim()
|
|
242
|
+
.replace(/\/+$/, '');
|
|
243
|
+
const aclPath = normalizedDir === '' || normalizedDir === '/' ? '/.relayfile.acl' : `${normalizedDir}/.relayfile.acl`;
|
|
244
|
+
return {
|
|
245
|
+
path: aclPath,
|
|
246
|
+
content: JSON.stringify({ semantics: { permissions: rules } }),
|
|
247
|
+
encoding: 'utf-8',
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
if (files.length === 0) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const result = await writeBulkWrite(baseUrl, token, workspace, files, `seed-acl-${workspace}-${Date.now()}`);
|
|
254
|
+
if (result.errorCount > 0) {
|
|
255
|
+
const details = result.errors ? JSON.stringify(result.errors) : '[]';
|
|
256
|
+
throw new Error(`ACL seeding had ${result.errorCount} error(s) for workspace ${workspace}: ${details}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
export async function seedWorkspace(baseUrl, token, workspaceId, projectDir, excludeDirs) {
|
|
260
|
+
const workspace = normalizeWorkspaceId(workspaceId);
|
|
261
|
+
const rootDir = path.resolve(projectDir);
|
|
262
|
+
const excludes = normalizeExcludeDirs([...DEFAULT_EXCLUDED_DIRS, ...excludeDirs]);
|
|
263
|
+
const seedPaths = [];
|
|
264
|
+
collectSeedPaths(rootDir, '', excludes, seedPaths);
|
|
265
|
+
const allFiles = seedPaths
|
|
266
|
+
.sort((left, right) => left.localeCompare(right))
|
|
267
|
+
.map((filePath) => buildSeedFilePayload(filePath, rootDir));
|
|
268
|
+
let seededCount = 0;
|
|
269
|
+
let totalErrorCount = 0;
|
|
270
|
+
const collectedErrors = [];
|
|
271
|
+
for (let index = 0; index < allFiles.length; index += BATCH_SIZE) {
|
|
272
|
+
const batch = allFiles.slice(index, index + BATCH_SIZE);
|
|
273
|
+
const batchIndex = Math.floor(index / BATCH_SIZE);
|
|
274
|
+
const result = await writeBulkWrite(baseUrl, token, workspace, batch, `seed-workspace-${workspace}-${Date.now()}-${batchIndex}`);
|
|
275
|
+
seededCount += result.written;
|
|
276
|
+
totalErrorCount += result.errorCount;
|
|
277
|
+
if (Array.isArray(result.errors)) {
|
|
278
|
+
collectedErrors.push(...result.errors);
|
|
279
|
+
}
|
|
280
|
+
else if (result.errors && result.errorCount > 0) {
|
|
281
|
+
collectedErrors.push(result.errors);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (totalErrorCount > 0) {
|
|
285
|
+
const details = collectedErrors.length > 0 ? JSON.stringify(collectedErrors) : '[]';
|
|
286
|
+
throw new Error(`seedWorkspace had ${totalErrorCount} error(s) for workspace ${workspace}: ${details}`);
|
|
287
|
+
}
|
|
288
|
+
return seededCount;
|
|
289
|
+
}
|
|
290
|
+
function buildWorkflowAclRules(agents) {
|
|
291
|
+
const directories = new Set();
|
|
292
|
+
const normalizedAgents = agents.map((agent) => ({
|
|
293
|
+
name: String(agent.name ?? '').trim(),
|
|
294
|
+
acl: Object.fromEntries(Object.entries(agent.acl ?? {}).map(([dirPath, rules]) => [
|
|
295
|
+
normalizeAclDirectory(dirPath),
|
|
296
|
+
Array.isArray(rules) ? rules : [],
|
|
297
|
+
])),
|
|
298
|
+
}));
|
|
299
|
+
const reviewerNames = normalizedAgents
|
|
300
|
+
.map((agent) => agent.name)
|
|
301
|
+
.filter((name) => name !== '' && isReviewerAgent(name));
|
|
302
|
+
for (const agent of normalizedAgents) {
|
|
303
|
+
for (const dirPath of Object.keys(agent.acl)) {
|
|
304
|
+
directories.add(dirPath);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const merged = new Map();
|
|
308
|
+
for (const dirPath of [...directories].sort((left, right) => left.localeCompare(right))) {
|
|
309
|
+
const rules = new Set();
|
|
310
|
+
for (const reviewerName of reviewerNames) {
|
|
311
|
+
rules.add(`allow:agent:${reviewerName}:read`);
|
|
312
|
+
}
|
|
313
|
+
for (const agent of normalizedAgents) {
|
|
314
|
+
if (!agent.name) {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const agentRules = agent.acl[dirPath] ?? [];
|
|
318
|
+
const hasRead = agentRules.includes('read') || agentRules.includes('write');
|
|
319
|
+
const hasWrite = agentRules.includes('write');
|
|
320
|
+
if (hasRead) {
|
|
321
|
+
rules.add(`allow:agent:${agent.name}:read`);
|
|
322
|
+
}
|
|
323
|
+
else if (!isReviewerAgent(agent.name)) {
|
|
324
|
+
rules.add(`deny:agent:${agent.name}`);
|
|
325
|
+
}
|
|
326
|
+
if (hasWrite) {
|
|
327
|
+
rules.add(`allow:agent:${agent.name}:write`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (rules.size > 0) {
|
|
331
|
+
merged.set(dirPath, rules);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return Object.fromEntries([...merged.entries()].map(([dirPath, rules]) => [dirPath, [...rules].sort()]));
|
|
335
|
+
}
|
|
336
|
+
export async function seedWorkflowAcls({ relayfileUrl, adminToken, workspace, agents, }) {
|
|
337
|
+
const aclRules = buildWorkflowAclRules(agents);
|
|
338
|
+
if (Object.keys(aclRules).length === 0) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
await seedAclRules(relayfileUrl, adminToken, workspace, aclRules);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Test whether a workspace-relative file path is covered by an exclude entry.
|
|
345
|
+
*
|
|
346
|
+
* Exclude entries can be either a single directory/file name (matched against
|
|
347
|
+
* any path segment) or a nested relative path like `build/output` (matched
|
|
348
|
+
* against any contiguous run of segments at any depth). Both single-name and
|
|
349
|
+
* nested matches must align on segment boundaries.
|
|
350
|
+
*/
|
|
351
|
+
function isExcludedRelativePath(relativePath, excludes) {
|
|
352
|
+
const segments = relativePath.split('/');
|
|
353
|
+
if (DEFAULT_EXCLUDED_FILES.has(segments[segments.length - 1] ?? '')) {
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
for (const exclude of excludes) {
|
|
357
|
+
if (!exclude)
|
|
358
|
+
continue;
|
|
359
|
+
if (!exclude.includes('/')) {
|
|
360
|
+
if (segments.some((seg) => seg === exclude)) {
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const excludeSegments = exclude.split('/').filter(Boolean);
|
|
366
|
+
if (excludeSegments.length === 0)
|
|
367
|
+
continue;
|
|
368
|
+
const limit = segments.length - excludeSegments.length;
|
|
369
|
+
for (let start = 0; start <= limit; start++) {
|
|
370
|
+
let matched = true;
|
|
371
|
+
for (let i = 0; i < excludeSegments.length; i++) {
|
|
372
|
+
if (segments[start + i] !== excludeSegments[i]) {
|
|
373
|
+
matched = false;
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (matched) {
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
function getGitTrackedFiles(rootDir) {
|
|
385
|
+
try {
|
|
386
|
+
const output = execSync('git ls-files -z --cached --others --exclude-standard', {
|
|
387
|
+
cwd: rootDir,
|
|
388
|
+
encoding: 'utf-8',
|
|
389
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
390
|
+
});
|
|
391
|
+
const files = output.split('\0').filter(Boolean);
|
|
392
|
+
return files;
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function collectAllFiles(rootDir, excludeDirs) {
|
|
399
|
+
const files = [];
|
|
400
|
+
const stack = [''];
|
|
401
|
+
while (stack.length > 0) {
|
|
402
|
+
const currentRelative = stack.pop();
|
|
403
|
+
const absoluteDir = path.join(rootDir, currentRelative);
|
|
404
|
+
let entries;
|
|
405
|
+
try {
|
|
406
|
+
entries = fs.readdirSync(absoluteDir, { withFileTypes: true });
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
for (const entry of entries) {
|
|
412
|
+
if (excludeDirs.has(entry.name))
|
|
413
|
+
continue;
|
|
414
|
+
if (DEFAULT_EXCLUDED_FILES.has(entry.name))
|
|
415
|
+
continue;
|
|
416
|
+
const nextRelative = currentRelative ? `${currentRelative}/${entry.name}` : entry.name;
|
|
417
|
+
if (excludeDirs.has(nextRelative))
|
|
418
|
+
continue;
|
|
419
|
+
if (entry.isDirectory()) {
|
|
420
|
+
stack.push(nextRelative);
|
|
421
|
+
}
|
|
422
|
+
else if (entry.isFile()) {
|
|
423
|
+
files.push(nextRelative);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return files;
|
|
428
|
+
}
|
|
429
|
+
async function createTarBuffer(rootDir, files) {
|
|
430
|
+
const tarStream = tar.create({ gzip: true, cwd: rootDir, portable: true, follow: true }, files);
|
|
431
|
+
const chunks = [];
|
|
432
|
+
for await (const chunk of tarStream) {
|
|
433
|
+
chunks.push(Buffer.from(chunk));
|
|
434
|
+
}
|
|
435
|
+
return Buffer.concat(chunks);
|
|
436
|
+
}
|
|
437
|
+
export async function seedWorkspaceTar(baseUrl, token, workspaceId, projectDir, excludeDirs) {
|
|
438
|
+
const workspace = normalizeWorkspaceId(workspaceId);
|
|
439
|
+
const rootDir = path.resolve(projectDir);
|
|
440
|
+
const excludes = normalizeExcludeDirs([...DEFAULT_EXCLUDED_DIRS, ...excludeDirs]);
|
|
441
|
+
const gitFiles = getGitTrackedFiles(rootDir);
|
|
442
|
+
const rawFiles = gitFiles ?? collectAllFiles(rootDir, excludes);
|
|
443
|
+
const files = gitFiles ? rawFiles.filter((f) => !isExcludedRelativePath(f, excludes)) : rawFiles;
|
|
444
|
+
if (files.length === 0) {
|
|
445
|
+
return 0;
|
|
446
|
+
}
|
|
447
|
+
const tarball = await createTarBuffer(rootDir, files);
|
|
448
|
+
// Detach into a plain Uint8Array view so we don't expose the underlying
|
|
449
|
+
// Node Buffer pool through the request body.
|
|
450
|
+
const body = new Uint8Array(tarball.buffer, tarball.byteOffset, tarball.byteLength).slice();
|
|
451
|
+
const url = `${normalizeBaseUrl(baseUrl)}/v1/workspaces/${encodeURIComponent(workspace)}/fs/import`;
|
|
452
|
+
const response = await fetch(url, {
|
|
453
|
+
method: 'POST',
|
|
454
|
+
headers: {
|
|
455
|
+
Authorization: `Bearer ${token}`,
|
|
456
|
+
'Content-Type': 'application/gzip',
|
|
457
|
+
'X-Correlation-Id': `seed-tar-${workspace}-${Date.now()}`,
|
|
458
|
+
},
|
|
459
|
+
body,
|
|
460
|
+
});
|
|
461
|
+
if (response.status === 404) {
|
|
462
|
+
// Tar import not supported — fall back to batch upload
|
|
463
|
+
return seedWorkspace(baseUrl, token, workspaceId, projectDir, excludeDirs);
|
|
464
|
+
}
|
|
465
|
+
if (!response.ok) {
|
|
466
|
+
const body = await response.text().catch(() => '');
|
|
467
|
+
throw new Error(`tar import failed for workspace ${workspace}: HTTP ${response.status} ${body}`.trim());
|
|
468
|
+
}
|
|
469
|
+
const raw = await response.text();
|
|
470
|
+
if (!raw.trim()) {
|
|
471
|
+
return files.length;
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
const parsed = JSON.parse(raw);
|
|
475
|
+
return typeof parsed.imported === 'number' ? parsed.imported : files.length;
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return files.length;
|
|
479
|
+
}
|
|
480
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@relayfile/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|
|
@@ -26,6 +26,14 @@
|
|
|
26
26
|
"types": "./dist/mount-harness.d.ts",
|
|
27
27
|
"default": "./dist/mount-harness.js"
|
|
28
28
|
},
|
|
29
|
+
"./workspace-seeder": {
|
|
30
|
+
"types": "./dist/workspace-seeder.d.ts",
|
|
31
|
+
"default": "./dist/workspace-seeder.js"
|
|
32
|
+
},
|
|
33
|
+
"./workspace-mount": {
|
|
34
|
+
"types": "./dist/workspace-mount.d.ts",
|
|
35
|
+
"default": "./dist/workspace-mount.js"
|
|
36
|
+
},
|
|
29
37
|
"./dist/*": "./dist/*",
|
|
30
38
|
"./package.json": "./package.json"
|
|
31
39
|
},
|
|
@@ -47,8 +55,11 @@
|
|
|
47
55
|
"prepublishOnly": "npm run build"
|
|
48
56
|
},
|
|
49
57
|
"dependencies": {
|
|
50
|
-
"@relayfile/core": "0.
|
|
58
|
+
"@relayfile/core": "0.8.0",
|
|
59
|
+
"ignore": "^7.0.5",
|
|
60
|
+
"tar": "^7.5.10"
|
|
51
61
|
},
|
|
62
|
+
"peerDependencies": {},
|
|
52
63
|
"devDependencies": {
|
|
53
64
|
"typescript": "^5.7.3",
|
|
54
65
|
"vitest": "^3.0.0"
|