Skip to content

Commit 70d6a98

Browse files
Add exists() method to check file/directory existence
Implements a new exists() method across the entire SDK stack that checks whether a file or directory exists at a given path. The method returns a boolean similar to Python's os.path.exists() and JavaScript's fs.existsSync(). Implementation: - Add FileExistsResult and FileExistsRequest types to shared package - Add /api/exists endpoint handler in container layer - Add exists() method to FileClient in SDK layer - Expose exists() through Sandbox class and ExecutionSession wrapper - Update ISandbox and ExecutionSession interfaces Testing: - Add unit tests for FileClient.exists() - Add unit tests for FileHandler.handleExists() - Add E2E test for file and directory existence checks - Add endpoint to E2E test worker 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Naresh <[email protected]>
1 parent 465ce06 commit 70d6a98

File tree

11 files changed

+326
-1
lines changed

11 files changed

+326
-1
lines changed

.changeset/add-exists-method.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@cloudflare/sandbox": minor
3+
---
4+
5+
Add exists() method to check if a file or directory exists
6+
7+
This adds a new `exists()` method to the SDK that checks whether a file or directory exists at a given path. The method returns a boolean indicating existence, similar to Python's `os.path.exists()` and JavaScript's `fs.existsSync()`.
8+
9+
The implementation is end-to-end:
10+
- New `FileExistsResult` and `FileExistsRequest` types in shared package
11+
- Handler endpoint at `/api/exists` in container layer
12+
- Client method in `FileClient` and `Sandbox` classes
13+
- Full test coverage (unit tests and E2E tests)

packages/sandbox-container/src/handlers/file-handler.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type {
22
DeleteFileResult,
3+
FileExistsResult,
34
FileStreamEvent,
4-
ListFilesResult,Logger,
5+
ListFilesResult,Logger,
56
MkdirResult,
67
MoveFileResult,
78
ReadFileResult,
@@ -12,6 +13,7 @@ import { ErrorCode } from '@repo/shared/errors';
1213

1314
import type {
1415
DeleteFileRequest,
16+
FileExistsRequest,
1517
ListFilesRequest,
1618
MkdirRequest,
1719
MoveFileRequest,
@@ -52,6 +54,8 @@ export class FileHandler extends BaseHandler<Request, Response> {
5254
return await this.handleMkdir(request, context);
5355
case '/api/list-files':
5456
return await this.handleListFiles(request, context);
57+
case '/api/exists':
58+
return await this.handleExists(request, context);
5559
default:
5660
return this.createErrorResponse({
5761
message: 'Invalid file endpoint',
@@ -277,4 +281,23 @@ export class FileHandler extends BaseHandler<Request, Response> {
277281
return this.createErrorResponse(result.error, context);
278282
}
279283
}
284+
285+
private async handleExists(request: Request, context: RequestContext): Promise<Response> {
286+
const body = await this.parseRequestBody<FileExistsRequest>(request);
287+
288+
const result = await this.fileService.exists(body.path, body.sessionId);
289+
290+
if (result.success) {
291+
const response: FileExistsResult = {
292+
success: true,
293+
path: body.path,
294+
exists: result.data,
295+
timestamp: new Date().toISOString(),
296+
};
297+
298+
return this.createTypedResponse(response, context);
299+
} else {
300+
return this.createErrorResponse(result.error, context);
301+
}
302+
}
280303
}

packages/sandbox-container/tests/handlers/file-handler.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { beforeEach, describe, expect, it, vi } from "bun:test";
22
import type {
33
DeleteFileResult,
4+
FileExistsResult,
45
MkdirResult,
56
MoveFileResult,
67
ReadFileResult,
@@ -486,6 +487,90 @@ describe('FileHandler', () => {
486487
});
487488
});
488489

490+
describe('handleExists - POST /api/exists', () => {
491+
it('should return true when file exists', async () => {
492+
const existsData = {
493+
path: '/tmp/test.txt',
494+
sessionId: 'session-123'
495+
};
496+
497+
(mockFileService.exists as any).mockResolvedValue({
498+
success: true,
499+
data: true
500+
});
501+
502+
const request = new Request('http://localhost:3000/api/exists', {
503+
method: 'POST',
504+
headers: { 'Content-Type': 'application/json' },
505+
body: JSON.stringify(existsData)
506+
});
507+
508+
const response = await fileHandler.handle(request, mockContext);
509+
510+
expect(response.status).toBe(200);
511+
const responseData = await response.json() as FileExistsResult;
512+
expect(responseData.success).toBe(true);
513+
expect(responseData.exists).toBe(true);
514+
expect(responseData.path).toBe('/tmp/test.txt');
515+
expect(responseData.timestamp).toBeDefined();
516+
517+
expect(mockFileService.exists).toHaveBeenCalledWith('/tmp/test.txt', 'session-123');
518+
});
519+
520+
it('should return false when file does not exist', async () => {
521+
const existsData = {
522+
path: '/tmp/nonexistent.txt',
523+
sessionId: 'session-123'
524+
};
525+
526+
(mockFileService.exists as any).mockResolvedValue({
527+
success: true,
528+
data: false
529+
});
530+
531+
const request = new Request('http://localhost:3000/api/exists', {
532+
method: 'POST',
533+
headers: { 'Content-Type': 'application/json' },
534+
body: JSON.stringify(existsData)
535+
});
536+
537+
const response = await fileHandler.handle(request, mockContext);
538+
539+
expect(response.status).toBe(200);
540+
const responseData = await response.json() as FileExistsResult;
541+
expect(responseData.success).toBe(true);
542+
expect(responseData.exists).toBe(false);
543+
});
544+
545+
it('should handle errors when checking file existence', async () => {
546+
const existsData = {
547+
path: '/invalid/path',
548+
sessionId: 'session-123'
549+
};
550+
551+
(mockFileService.exists as any).mockResolvedValue({
552+
success: false,
553+
error: {
554+
message: 'Invalid path',
555+
code: 'VALIDATION_FAILED',
556+
httpStatus: 400
557+
}
558+
});
559+
560+
const request = new Request('http://localhost:3000/api/exists', {
561+
method: 'POST',
562+
headers: { 'Content-Type': 'application/json' },
563+
body: JSON.stringify(existsData)
564+
});
565+
566+
const response = await fileHandler.handle(request, mockContext);
567+
568+
expect(response.status).toBe(400);
569+
const responseData = await response.json() as ErrorResponse;
570+
expect(responseData.code).toBe('VALIDATION_FAILED');
571+
});
572+
});
573+
489574
describe('route handling', () => {
490575
it('should return 500 for invalid endpoints', async () => {
491576
const request = new Request('http://localhost:3000/api/invalid-operation', {

packages/sandbox/src/clients/file-client.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
DeleteFileResult,
3+
FileExistsResult,
34
ListFilesOptions,
45
ListFilesResult,
56
MkdirResult,
@@ -266,4 +267,29 @@ export class FileClient extends BaseHttpClient {
266267
throw error;
267268
}
268269
}
270+
271+
/**
272+
* Check if a file or directory exists
273+
* @param path - Path to check
274+
* @param sessionId - The session ID for this operation
275+
*/
276+
async exists(
277+
path: string,
278+
sessionId: string
279+
): Promise<FileExistsResult> {
280+
try {
281+
const data = {
282+
path,
283+
sessionId,
284+
};
285+
286+
const response = await this.post<FileExistsResult>('/api/exists', data);
287+
288+
this.logSuccess('Path existence checked', `${path} (exists: ${response.exists})`);
289+
return response;
290+
} catch (error) {
291+
this.logError('exists', error);
292+
throw error;
293+
}
294+
}
269295
}

packages/sandbox/src/sandbox.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,11 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
697697
return this.client.files.listFiles(path, session, options);
698698
}
699699

700+
async exists(path: string, sessionId?: string) {
701+
const session = sessionId ?? await this.ensureDefaultSession();
702+
return this.client.files.exists(path, session);
703+
}
704+
700705
async exposePort(port: number, options: { name?: string; hostname: string }) {
701706
// Check if hostname is workers.dev domain (doesn't support wildcard subdomains)
702707
if (options.hostname.endsWith('.workers.dev')) {
@@ -934,6 +939,7 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
934939
renameFile: (oldPath, newPath) => this.renameFile(oldPath, newPath, sessionId),
935940
moveFile: (sourcePath, destPath) => this.moveFile(sourcePath, destPath, sessionId),
936941
listFiles: (path, options) => this.client.files.listFiles(path, sessionId, options),
942+
exists: (path) => this.exists(path, sessionId),
937943

938944
// Git operations
939945
gitCheckout: (repoUrl, options) => this.gitCheckout(repoUrl, { ...options, sessionId }),

packages/sandbox/tests/file-client.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
DeleteFileResult,
3+
FileExistsResult,
34
ListFilesResult,
45
MkdirResult,
56
MoveFileResult,
@@ -584,6 +585,81 @@ database:
584585
});
585586
});
586587

588+
describe('exists', () => {
589+
it('should return true when file exists', async () => {
590+
const mockResponse: FileExistsResult = {
591+
success: true,
592+
path: '/workspace/test.txt',
593+
exists: true,
594+
timestamp: '2023-01-01T00:00:00Z',
595+
};
596+
597+
mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
598+
599+
const result = await client.exists('/workspace/test.txt', 'session-exists');
600+
601+
expect(result.success).toBe(true);
602+
expect(result.exists).toBe(true);
603+
expect(result.path).toBe('/workspace/test.txt');
604+
});
605+
606+
it('should return false when file does not exist', async () => {
607+
const mockResponse: FileExistsResult = {
608+
success: true,
609+
path: '/workspace/nonexistent.txt',
610+
exists: false,
611+
timestamp: '2023-01-01T00:00:00Z',
612+
};
613+
614+
mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
615+
616+
const result = await client.exists('/workspace/nonexistent.txt', 'session-exists');
617+
618+
expect(result.success).toBe(true);
619+
expect(result.exists).toBe(false);
620+
});
621+
622+
it('should return true when directory exists', async () => {
623+
const mockResponse: FileExistsResult = {
624+
success: true,
625+
path: '/workspace/some-dir',
626+
exists: true,
627+
timestamp: '2023-01-01T00:00:00Z',
628+
};
629+
630+
mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
631+
632+
const result = await client.exists('/workspace/some-dir', 'session-exists');
633+
634+
expect(result.success).toBe(true);
635+
expect(result.exists).toBe(true);
636+
});
637+
638+
it('should send correct request payload', async () => {
639+
const mockResponse: FileExistsResult = {
640+
success: true,
641+
path: '/test/path',
642+
exists: true,
643+
timestamp: '2023-01-01T00:00:00Z',
644+
};
645+
646+
mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
647+
648+
await client.exists('/test/path', 'session-test');
649+
650+
expect(mockFetch).toHaveBeenCalledWith(
651+
expect.stringContaining('/api/exists'),
652+
expect.objectContaining({
653+
method: 'POST',
654+
body: JSON.stringify({
655+
path: '/test/path',
656+
sessionId: 'session-test',
657+
})
658+
})
659+
);
660+
});
661+
});
662+
587663
describe('error handling', () => {
588664
it('should handle network failures gracefully', async () => {
589665
mockFetch.mockRejectedValue(new Error('Network connection failed'));

packages/shared/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type {
3131
DeleteFileRequest,
3232
ExecuteRequest,
3333
ExposePortRequest,
34+
FileExistsRequest,
3435
GitCheckoutRequest,
3536
MkdirRequest,
3637
MoveFileRequest,
@@ -53,6 +54,7 @@ export type {
5354
ExecOptions,
5455
ExecResult,
5556
ExecutionSession,
57+
FileExistsResult,
5658
// File streaming types
5759
FileChunk,
5860
FileInfo,

packages/shared/src/request-types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ export interface MkdirRequest {
8585
sessionId?: string;
8686
}
8787

88+
/**
89+
* Request to check if a file or directory exists
90+
*/
91+
export interface FileExistsRequest {
92+
path: string;
93+
sessionId?: string;
94+
}
95+
8896
/**
8997
* Request to expose a port
9098
*/

packages/shared/src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,13 @@ export interface MoveFileResult {
343343
exitCode?: number;
344344
}
345345

346+
export interface FileExistsResult {
347+
success: boolean;
348+
path: string;
349+
exists: boolean;
350+
timestamp: string;
351+
}
352+
346353
export interface FileInfo {
347354
name: string;
348355
absolutePath: string;
@@ -603,6 +610,7 @@ export interface ExecutionSession {
603610
renameFile(oldPath: string, newPath: string): Promise<RenameFileResult>;
604611
moveFile(sourcePath: string, destinationPath: string): Promise<MoveFileResult>;
605612
listFiles(path: string, options?: ListFilesOptions): Promise<ListFilesResult>;
613+
exists(path: string): Promise<FileExistsResult>;
606614

607615
// Git operations
608616
gitCheckout(repoUrl: string, options?: { branch?: string; targetDir?: string }): Promise<GitCheckoutResult>;
@@ -647,6 +655,7 @@ export interface ISandbox {
647655
renameFile(oldPath: string, newPath: string): Promise<RenameFileResult>;
648656
moveFile(sourcePath: string, destinationPath: string): Promise<MoveFileResult>;
649657
listFiles(path: string, options?: ListFilesOptions): Promise<ListFilesResult>;
658+
exists(path: string): Promise<FileExistsResult>;
650659

651660
// Git operations
652661
gitCheckout(repoUrl: string, options?: { branch?: string; targetDir?: string }): Promise<GitCheckoutResult>;

0 commit comments

Comments
 (0)