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
256 lines • 9.45 kB
JavaScript
/**
* Storage Configuration Loader and Validator
*
* Reads `.aiwg/storage.config` (project-local), validates it against the
* v1 schema, and returns a typed `StorageConfig`. Absence of the file is
* a no-op: every subsystem defaults to `fs` rooted under `.aiwg/`.
*
* Validation is hand-rolled rather than schema-validator-driven to avoid
* adding an `ajv` dependency for a single file. The published JSON
* Schema (`.aiwg/architecture/schemas/storage.config.v1.json`) remains
* canonical for editor tooling and external consumers.
*
* @design @.aiwg/architecture/storage-design.md
* @issue #934
* @issue #953
*/
import { existsSync } from 'fs';
import { readFile } from 'fs/promises';
import { resolve, isAbsolute } from 'path';
import { homedir } from 'os';
import { BACKEND_TYPES, SUBSYSTEM_KEYS, } from './types.js';
/**
* Property names that must never appear at any nesting depth in
* `storage.config`. Defense-in-depth against credentials being written
* to disk. The published JSON Schema also enforces this; the recursive
* runtime walk below catches custom backend extensions that bypass the
* static `additionalProperties: false`.
*/
export const FORBIDDEN_CREDENTIAL_KEYS = [
'token',
'password',
'secret',
'apiKey',
'api_key',
'accessKey',
'accessKeyId',
'secretAccessKey',
];
const STORAGE_CONFIG_FILENAME = 'storage.config';
const DEFAULT_AIWG_DIR = '.aiwg';
/** Default subsystem-to-relative-path mapping for the `fs` backend. */
export const DEFAULT_SUBSYSTEM_ROOTS = {
memory: 'memory',
reflections: 'reflections',
kb: 'kb',
activity_log: '.', // activity_log is a single file, not a directory
provenance: 'provenance',
research: 'research',
media: 'media',
sandbox_identity: 'sandbox-identity',
};
/**
* Resolve the path where `.aiwg/storage.config` is expected for a given
* project root. Does not check existence.
*/
export function storageConfigPath(projectRoot) {
return resolve(projectRoot, DEFAULT_AIWG_DIR, STORAGE_CONFIG_FILENAME);
}
/**
* Load and validate `.aiwg/storage.config` from `projectRoot`.
*
* Returns `null` if the file is absent (caller falls back to defaults).
* Throws a descriptive error for malformed JSON, unsupported version,
* unknown subsystem keys, unknown backend types, or any credential-named
* property at any depth.
*/
export async function loadStorageConfig(projectRoot) {
const cfgPath = storageConfigPath(projectRoot);
if (!existsSync(cfgPath))
return null;
let raw;
try {
raw = await readFile(cfgPath, 'utf-8');
}
catch (err) {
throw new Error(`Failed to read ${cfgPath}: ${err.message}`);
}
let parsed;
try {
parsed = JSON.parse(raw);
}
catch (err) {
throw new Error(`Invalid JSON in ${cfgPath}: ${err.message}`);
}
return validateStorageConfig(parsed, cfgPath);
}
/**
* Validate an already-parsed object as a `StorageConfig`. Exposed for
* use by `aiwg doctor` and tests.
*/
export function validateStorageConfig(parsed, source = '<input>') {
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`${source}: storage config must be an object`);
}
const obj = parsed;
if (obj['version'] !== '1') {
throw new Error(`${source}: unsupported schema version ${JSON.stringify(obj['version'])} ` +
`(expected "1"). Update the AIWG CLI to read newer config versions.`);
}
walkRejectingCredentials(obj, source, 'storage');
const roots = validateRoots(obj['roots'], source);
const backends = validateBackends(obj['backends'], source);
let fallback;
if (obj['fallback'] !== undefined) {
if (obj['fallback'] !== 'cache_and_warn' && obj['fallback'] !== 'block') {
throw new Error(`${source}: storage.fallback must be "cache_and_warn" or "block" (got ${JSON.stringify(obj['fallback'])})`);
}
fallback = obj['fallback'];
}
const result = { version: '1' };
if (roots)
result.roots = roots;
if (backends)
result.backends = backends;
if (fallback)
result.fallback = fallback;
return result;
}
function validateRoots(raw, source) {
if (raw === undefined)
return undefined;
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
throw new Error(`${source}: storage.roots must be an object`);
}
const out = {};
for (const [k, v] of Object.entries(raw)) {
if (!isSubsystemKey(k)) {
throw new Error(`${source}: roots.${k} is not a known subsystem. ` +
`Allowed: ${SUBSYSTEM_KEYS.join(', ')}`);
}
if (typeof v !== 'string' || v.length === 0) {
throw new Error(`${source}: roots.${k} must be a non-empty string path`);
}
out[k] = v;
}
return out;
}
function validateBackends(raw, source) {
if (raw === undefined)
return undefined;
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
throw new Error(`${source}: storage.backends must be an object`);
}
const out = {};
for (const [k, v] of Object.entries(raw)) {
if (!isSubsystemKey(k)) {
throw new Error(`${source}: backends.${k} is not a known subsystem. ` +
`Allowed: ${SUBSYSTEM_KEYS.join(', ')}`);
}
out[k] = validateBackendConfig(v, `${source}: backends.${k}`);
}
return out;
}
function validateBackendConfig(raw, source) {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
throw new Error(`${source} must be an object`);
}
const obj = raw;
const type = obj['type'];
if (typeof type !== 'string' || !BACKEND_TYPES.includes(type)) {
throw new Error(`${source}.type must be one of ${BACKEND_TYPES.join(', ')} (got ${JSON.stringify(type)})`);
}
// Per-backend required fields. Optional fields are passed through as-is
// — the adapter is responsible for any further validation it needs.
switch (type) {
case 'fs':
return { type: 'fs' };
case 'obsidian':
requireString(obj, 'vault', source);
return obj;
case 'logseq':
requireString(obj, 'graph', source);
return obj;
case 'notion': {
const parent = obj['parent'];
if (typeof parent !== 'object' || parent === null) {
throw new Error(`${source}.parent must be an object with pageId or databaseId`);
}
const p = parent;
const hasPage = typeof p['pageId'] === 'string' && p['pageId'].length > 0;
const hasDb = typeof p['databaseId'] === 'string' && p['databaseId'].length > 0;
if (hasPage === hasDb) {
throw new Error(`${source}.parent must specify exactly one of pageId or databaseId`);
}
return obj;
}
case 'anythingllm':
requireString(obj, 'baseUrl', source);
requireString(obj, 'workspace', source);
return obj;
case 'fortemi':
return obj;
case 's3':
requireString(obj, 'bucket', source);
return obj;
case 'webdav':
requireString(obj, 'url', source);
return obj;
default:
throw new Error(`${source}: unhandled backend type ${type}`);
}
}
function requireString(obj, key, source) {
const v = obj[key];
if (typeof v !== 'string' || v.length === 0) {
throw new Error(`${source}.${key} must be a non-empty string`);
}
}
function isSubsystemKey(k) {
return SUBSYSTEM_KEYS.includes(k);
}
/**
* Recursively reject any property whose name appears in
* FORBIDDEN_CREDENTIAL_KEYS. Throws on first hit with the full path.
*/
export function walkRejectingCredentials(value, source, path) {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
walkRejectingCredentials(value[i], source, `${path}[${i}]`);
}
return;
}
if (typeof value !== 'object' || value === null)
return;
for (const [k, v] of Object.entries(value)) {
if (FORBIDDEN_CREDENTIAL_KEYS.includes(k)) {
throw new Error(`${source}: forbidden credential property "${path}.${k}" — ` +
`tokens, passwords, and API keys must come from environment variables, never from storage.config.`);
}
walkRejectingCredentials(v, source, `${path}.${k}`);
}
}
/**
* Resolve the absolute filesystem path for an `fs`-backed subsystem,
* applying `roots` overrides if present. Used by the fs adapter.
*
* Resolution order:
* 1. `roots[subsystem]` if set, expanding `~` and accepting absolute paths
* 2. `<projectRoot>/.aiwg/<DEFAULT_SUBSYSTEM_ROOTS[subsystem]>`
*/
export function resolveSubsystemRoot(subsystem, projectRoot, config) {
const override = config?.roots?.[subsystem];
if (override)
return expandPath(override, projectRoot);
return resolve(projectRoot, DEFAULT_AIWG_DIR, DEFAULT_SUBSYSTEM_ROOTS[subsystem]);
}
function expandPath(p, projectRoot) {
if (p.startsWith('~/'))
return resolve(homedir(), p.slice(2));
if (p === '~')
return homedir();
if (isAbsolute(p))
return p;
return resolve(projectRoot, p);
}
//# sourceMappingURL=config.js.map