UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

134 lines 4.87 kB
/** * Filesystem Storage Adapter * * The default backend. Wraps `fs.promises` and honors per-subsystem root * overrides from `storage.config`. Output is byte-identical to the * pre-abstraction direct-fs calls so consumer migrations stay safe. * * @design @.aiwg/architecture/storage-design.md (§5.1) * @issue #934 * @issue #956 */ import { existsSync } from 'fs'; import { appendFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'fs/promises'; import { dirname, join, relative, resolve, sep } from 'path'; export class FilesystemAdapter { /** Absolute path where this subsystem's content lives. */ root; constructor(root) { this.root = resolve(root); } /** * Path traversal guard. Rejects `..`, leading `/`, leading `~`, and * backslashes (which on POSIX would be treated as filename chars but * are almost always a Windows-vs-POSIX bug). Also rejects empty paths. */ resolveSafe(relPath) { if (typeof relPath !== 'string' || relPath.length === 0) { throw new Error('storage(fs): path must be a non-empty string'); } if (relPath.includes('\\')) { throw new Error(`storage(fs): backslash not allowed in path "${relPath}"`); } if (relPath.startsWith('/') || relPath.startsWith('~')) { throw new Error(`storage(fs): absolute paths not allowed: "${relPath}"`); } const segments = relPath.split('/'); if (segments.some((s) => s === '..')) { throw new Error(`storage(fs): ".." traversal not allowed in "${relPath}"`); } const abs = resolve(this.root, relPath); // Final guard: ensure the resolved path is still under root, even if // the segment check missed something unusual (e.g. empty segments). if (abs !== this.root && !abs.startsWith(this.root + sep)) { throw new Error(`storage(fs): resolved path escapes subsystem root: "${relPath}"`); } return abs; } async read(path) { const abs = this.resolveSafe(path); if (!existsSync(abs)) return null; return readFile(abs, 'utf-8'); } async write(path, content, _meta) { const abs = this.resolveSafe(path); await mkdir(dirname(abs), { recursive: true }); await writeFile(abs, content, 'utf-8'); } /** * Atomic append. Uses fs.appendFile, which opens with O_APPEND so the * kernel guarantees atomicity for writes ≤ PIPE_BUF (4096 bytes on * Linux). Concurrent appenders interleave at line granularity rather * than racing read-then-write. See #976. */ async append(path, content) { const abs = this.resolveSafe(path); await mkdir(dirname(abs), { recursive: true }); await appendFile(abs, content, 'utf-8'); } async list(prefix) { // Empty prefix = list everything under root. A non-empty prefix // restricts to entries whose subsystem-relative path starts with it // (after path-normalizing the prefix itself). if (typeof prefix !== 'string') { throw new Error('storage(fs): list prefix must be a string'); } if (prefix.length > 0) { // resolveSafe also validates the prefix shape this.resolveSafe(prefix); } if (!existsSync(this.root)) return []; const out = []; await walkInto(this.root, this.root, prefix, out); return out; } async delete(path) { const abs = this.resolveSafe(path); if (!existsSync(abs)) return; await rm(abs, { force: true }); } } async function walkInto(root, dir, prefix, out) { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { const full = join(dir, e.name); const rel = relative(root, full).split(sep).join('/'); if (e.isDirectory()) { // Descend if the dir could contain matches if (prefix === '' || rel.startsWith(prefix) || prefix.startsWith(rel + '/')) { await walkInto(root, full, prefix, out); } continue; } if (!e.isFile()) continue; if (prefix !== '' && !rel.startsWith(prefix)) continue; let size; let modifiedAt; try { const s = await stat(full); size = s.size; modifiedAt = s.mtime; } catch { // ignore stat failures } const entry = { path: rel }; if (size !== undefined) entry.size = size; if (modifiedAt !== undefined) entry.modifiedAt = modifiedAt; out.push(entry); } } //# sourceMappingURL=fs.js.map