Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/squad-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@
"./config/models": {
"types": "./dist/config/models.d.ts",
"import": "./dist/config/models.js"
},
"./storage": {
"types": "./dist/storage/index.d.ts",
"import": "./dist/storage/index.js"
}
},
"files": [
Expand Down
1 change: 1 addition & 0 deletions packages/squad-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,4 @@ export type {
// Base Roles (built-in role catalog)
export * from './roles/index.js';
export * from './platform/index.js';
export * from './storage/index.js';
84 changes: 84 additions & 0 deletions packages/squad-sdk/src/storage/fs-storage-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* FSStorageProvider — Node.js `fs` / `fs/promises` implementation of StorageProvider.
*
* Drop-in default for Wave 1. No call sites are migrated here — this is the
* foundation layer only. Wave 2 will replace direct fs calls across the codebase.
*/

import * as fsSync from 'fs';
import * as fs from 'fs/promises';
import * as path from 'path';
import type { StorageProvider, StorageProviderOptions } from './storage-provider.js';

export class FSStorageProvider implements StorageProvider {
constructor(_options?: StorageProviderOptions) {
// options reserved for future use (e.g. baseDir scoping)
}

// ---------------------------------------------------------------------------
// Async
// ---------------------------------------------------------------------------

async read(filePath: string): Promise<string> {
return fs.readFile(filePath, 'utf8');
}

async write(filePath: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, 'utf8');
}

async append(filePath: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.appendFile(filePath, content, 'utf8');
}

async exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}

async list(dir: string): Promise<string[]> {
try {
return await fs.readdir(dir);
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'ENOENT') return [];
throw err;
}
}

async delete(filePath: string): Promise<void> {
try {
await fs.unlink(filePath);
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code !== 'ENOENT') throw err;
// ENOENT → no-op
}
}

// ---------------------------------------------------------------------------
// Sync (deprecated after Wave 2)
// ---------------------------------------------------------------------------

/** @deprecated Use {@link read} instead. */
readSync(filePath: string): string {
return fsSync.readFileSync(filePath, 'utf8');
}

/** @deprecated Use {@link write} instead. */
writeSync(filePath: string, content: string): void {
fsSync.mkdirSync(path.dirname(filePath), { recursive: true });
fsSync.writeFileSync(filePath, content, 'utf8');
}

/** @deprecated Use {@link exists} instead. */
existsSync(filePath: string): boolean {
return fsSync.existsSync(filePath);
}
}
2 changes: 2 additions & 0 deletions packages/squad-sdk/src/storage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { StorageProvider, StorageProviderOptions } from './storage-provider.js';
export { FSStorageProvider } from './fs-storage-provider.js';
67 changes: 67 additions & 0 deletions packages/squad-sdk/src/storage/storage-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* StorageProvider — abstract I/O interface for the Squad SDK.
*
* Wave 1: defines the contract. FSStorageProvider is the default.
* Wave 2: call-site migration will retire the sync methods.
*/

/**
* Options passed to StorageProvider implementations.
* Reserved for future use (e.g. base directory scoping, encoding).
*/
export interface StorageProviderOptions {
/** Optional root directory to resolve relative paths against. */
baseDir?: string;
}

/**
* Core storage abstraction used by the Squad SDK.
*
* Async methods are preferred for all new code.
* Sync methods exist for legacy compatibility and will be deprecated after Wave 2.
*/
export interface StorageProvider {
// -------------------------------------------------------------------------
// Async (preferred — use for new code)
// -------------------------------------------------------------------------

/** Read the full contents of a file. Throws if the file does not exist. */
read(path: string): Promise<string>;

/** Write content to a file, creating parent directories as needed. */
write(path: string, content: string): Promise<void>;

/** Append content to a file. Creates the file (and parent dirs) if missing. */
append(path: string, content: string): Promise<void>;

/** Return true if the path exists on the filesystem. */
exists(path: string): Promise<boolean>;

/** List the names of entries directly inside a directory. */
list(dir: string): Promise<string[]>;

/** Delete a file. No-op if the file does not exist. */
delete(path: string): Promise<void>;

// -------------------------------------------------------------------------
// Sync (legacy — deprecated after Wave 2 migration)
// -------------------------------------------------------------------------

/**
* Read the full contents of a file synchronously.
* @deprecated Use {@link read} (async) instead. Will be removed after Wave 2.
*/
readSync(path: string): string;

/**
* Write content to a file synchronously, creating parent directories as needed.
* @deprecated Use {@link write} (async) instead. Will be removed after Wave 2.
*/
writeSync(path: string, content: string): void;

/**
* Return true if the path exists synchronously.
* @deprecated Use {@link exists} (async) instead. Will be removed after Wave 2.
*/
existsSync(path: string): boolean;
}
163 changes: 163 additions & 0 deletions test/storage-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* StorageProvider — FSStorageProvider tests (Wave 1)
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'fs';
import { FSStorageProvider } from '@bradygaster/squad-sdk/storage';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function tmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'squad-storage-test-'));
}

// ---------------------------------------------------------------------------
// Suite
// ---------------------------------------------------------------------------

describe('FSStorageProvider', () => {
let dir: string;
let provider: FSStorageProvider;

beforeEach(() => {
dir = tmpDir();
provider = new FSStorageProvider();
});

afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});

// -------------------------------------------------------------------------
// write / read
// -------------------------------------------------------------------------

it('write creates a file and read returns its contents', async () => {
const file = path.join(dir, 'hello.txt');
await provider.write(file, 'hello world');
expect(await provider.read(file)).toBe('hello world');
});

it('write creates parent directories automatically', async () => {
const file = path.join(dir, 'a', 'b', 'c', 'deep.txt');
await provider.write(file, 'nested');
expect(await provider.read(file)).toBe('nested');
});

it('read throws on a non-existent file', async () => {
const file = path.join(dir, 'missing.txt');
await expect(provider.read(file)).rejects.toThrow();
});

// -------------------------------------------------------------------------
// append
// -------------------------------------------------------------------------

it('append creates the file if it does not exist', async () => {
const file = path.join(dir, 'new.txt');
await provider.append(file, 'first');
expect(await provider.read(file)).toBe('first');
});

it('append adds to an existing file', async () => {
const file = path.join(dir, 'log.txt');
await provider.write(file, 'line1\n');
await provider.append(file, 'line2\n');
expect(await provider.read(file)).toBe('line1\nline2\n');
});

it('append creates parent directories automatically', async () => {
const file = path.join(dir, 'sub', 'log.txt');
await provider.append(file, 'content');
expect(await provider.read(file)).toBe('content');
});

// -------------------------------------------------------------------------
// exists
// -------------------------------------------------------------------------

it('exists returns true for an existing file', async () => {
const file = path.join(dir, 'present.txt');
await provider.write(file, '');
expect(await provider.exists(file)).toBe(true);
});

it('exists returns false for a missing file', async () => {
const file = path.join(dir, 'absent.txt');
expect(await provider.exists(file)).toBe(false);
});

// -------------------------------------------------------------------------
// list
// -------------------------------------------------------------------------

it('list returns filenames inside a directory', async () => {
await provider.write(path.join(dir, 'a.txt'), '');
await provider.write(path.join(dir, 'b.txt'), '');
const entries = await provider.list(dir);
expect(entries.sort()).toEqual(['a.txt', 'b.txt']);
});

it('list returns an empty array for an empty directory', async () => {
const sub = path.join(dir, 'empty');
fs.mkdirSync(sub);
expect(await provider.list(sub)).toEqual([]);
});

it('list returns an empty array for a non-existent directory', async () => {
expect(await provider.list(path.join(dir, 'no-such-dir'))).toEqual([]);
});

// -------------------------------------------------------------------------
// delete
// -------------------------------------------------------------------------

it('delete removes an existing file', async () => {
const file = path.join(dir, 'gone.txt');
await provider.write(file, 'bye');
await provider.delete(file);
expect(await provider.exists(file)).toBe(false);
});

it('delete is a no-op when the file does not exist', async () => {
const file = path.join(dir, 'never-existed.txt');
await expect(provider.delete(file)).resolves.toBeUndefined();
});

// -------------------------------------------------------------------------
// Sync methods
// -------------------------------------------------------------------------

it('writeSync + readSync round-trips correctly', () => {
const file = path.join(dir, 'sync.txt');
provider.writeSync(file, 'sync content');
expect(provider.readSync(file)).toBe('sync content');
});

it('writeSync creates parent directories', () => {
const file = path.join(dir, 'x', 'y', 'sync.txt');
provider.writeSync(file, 'deep sync');
expect(provider.readSync(file)).toBe('deep sync');
});

it('existsSync returns true for an existing file', () => {
const file = path.join(dir, 'sync-exists.txt');
provider.writeSync(file, '');
expect(provider.existsSync(file)).toBe(true);
});

it('existsSync returns false for a missing file', () => {
expect(provider.existsSync(path.join(dir, 'nope.txt'))).toBe(false);
});

it('existsSync matches exists for the same path', async () => {
const file = path.join(dir, 'parity.txt');
await provider.write(file, '');
expect(provider.existsSync(file)).toBe(await provider.exists(file));
});
});
Loading