Skip to content

Commit 8e7773e

Browse files
Add exists() method to check file/directory existence (#152)
* 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]> * Fix exists() type safety and error handling Address code review feedback on PR #152: - Fix TypeScript compilation by importing FileExistsRequest from @repo/shared - Add sessionId parameter to ISandbox.exists() for API consistency - Fix error handling to propagate execution failures properly Co-authored-by: Naresh <[email protected]> * Fix exists() to handle execution failures defensively Revert error propagation change from c6c6068. The exists() method should treat command execution failures as 'file does not exist' rather than propagating errors, matching standard behavior in Python's os.path.exists() and JavaScript's fs.existsSync(). This fixes the failing unit test: FileService > exists > should handle execution failures gracefully Co-authored-by: Naresh <[email protected]> * Add missing /api/exists route registration The exists() endpoint handler was implemented but the route was never registered in the container's router setup, causing e2e tests to fail with HTTP 500 errors. This adds the missing route registration for POST /api/exists to the file handler in routes/setup.ts. Co-authored-by: Naresh <[email protected]> * Use patch Add exists() method to check if a file or directory exists --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Naresh <[email protected]>
1 parent 93e4f04 commit 8e7773e

File tree

12 files changed

+333
-1
lines changed

12 files changed

+333
-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": patch
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,9 @@
11
import type {
22
DeleteFileResult,
3+
FileExistsRequest,
4+
FileExistsResult,
35
FileStreamEvent,
4-
ListFilesResult,Logger,
6+
ListFilesResult,Logger,
57
MkdirResult,
68
MoveFileResult,
79
ReadFileResult,
@@ -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/src/routes/setup.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ export function setupRoutes(router: Router, container: Container): void {
9191
middleware: [container.get('loggingMiddleware')],
9292
});
9393

94+
router.register({
95+
method: 'POST',
96+
path: '/api/exists',
97+
handler: async (req, ctx) => container.get('fileHandler').handle(req, ctx),
98+
middleware: [container.get('loggingMiddleware')],
99+
});
100+
94101
// Port management routes
95102
router.register({
96103
method: 'POST',

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, sessionId?: string): Promise<FileExistsResult>;
650659

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

0 commit comments

Comments
 (0)