// ============================================================================= // api.ts — Shared module for the templating-vault skill // // Part of: .opencode/skills/templating-vault/scripts/ // // Provides: config loading (cascading YAML), store resolution (prefix→directory // mapping), file I/O with atomic writes, per-key metadata, Nunjucks template // environment, type validation, and literal/write-once enforcement. // // Imported by all other vault scripts. Not a standalone CLI. // // DEPENDENCIES: nunjucks, js-yaml, Node.js >= 18 // ============================================================================= import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import nunjucks from 'nunjucks'; import yaml from 'js-yaml'; // ───────────────────────────────────────────────────────────────────────────── // TYPES // ───────────────────────────────────────────────────────────────────────────── export type AccessLevel = 'readonly' | 'write' | 'admin'; export interface StoreMapping { prefix: string; // dot-separated key prefix (empty = catch-all) path: string; // absolute or relative directory path access: AccessLevel; init?: { source: string; // YAML definition file to bootstrap from on_exists: 'ignore' | 'validate' | 'fix'; }; } export interface VaultConfig { stores: StoreMapping[]; } export interface KeyMeta { literal?: boolean; readonly?: boolean; write_once?: boolean; owner?: string; type?: string; // string | number | integer | positive_integer | boolean | url | hex | nonempty validate?: { regex?: string; message?: string; }; template?: boolean; on_absent?: string | number; // default value for missing template deps, or "error" description?: string; } export interface StoreDefaults { literal?: boolean; readonly?: boolean; write_once?: boolean; type?: string; template?: boolean; on_absent?: string | number; } export interface StoreYaml { defaults?: StoreDefaults; } export interface SchemaVariable { literal: boolean; type?: string; description?: string; validate?: { regex?: string; message?: string }; } export interface TemplateSchema { variables: Record; } export interface GetResult { key: string; value: string; found: boolean; metadata?: KeyMeta; evaluated?: boolean; // true if template was evaluated } // ───────────────────────────────────────────────────────────────────────────── // CONSTANTS // ───────────────────────────────────────────────────────────────────────────── const SKILL_DIR = path.resolve( path.dirname(new URL(import.meta.url).pathname), '..', ); const DEFAULT_STORE_DIR = '/tmp/templating-vault'; const BUILTIN_DEFAULTS: StoreDefaults = { literal: false, readonly: false, write_once: false, type: 'string', template: false, on_absent: 'error', }; // ───────────────────────────────────────────────────────────────────────────── // CONFIG LOADING // ───────────────────────────────────────────────────────────────────────────── /** * Load and merge vault configs from cascading sources. * Later sources override earlier ones (by prefix match). */ export function loadConfig(cliConfigPath?: string, cliStoreDirs?: string[]): VaultConfig { const configs: VaultConfig[] = []; // 1. Built-in default configs.push({ stores: [{ prefix: '', path: DEFAULT_STORE_DIR, access: 'write' }], }); // 2. Repo-level const repoConfig = path.join(SKILL_DIR, 'config.yaml'); if (fs.existsSync(repoConfig)) { const parsed = loadConfigFile(repoConfig); if (parsed) configs.push(parsed); } // 3. User-level const userConfig = path.join(os.homedir(), '.config', 'templating-vault', 'config.yaml'); if (fs.existsSync(userConfig)) { const parsed = loadConfigFile(userConfig); if (parsed) configs.push(parsed); } // 4. CLI config file if (cliConfigPath && fs.existsSync(cliConfigPath)) { const parsed = loadConfigFile(cliConfigPath); if (parsed) configs.push(parsed); } // 5. CLI --store-dir entries (may contain multiple, with optional prefix:path syntax) if (cliStoreDirs && cliStoreDirs.length > 0) { const cliStores: StoreMapping[] = []; for (const entry of cliStoreDirs) { const parsed = parseStoreDirEntry(entry); cliStores.push(parsed); } configs.push({ stores: cliStores }); } const merged = mergeConfigs(configs); // Validate: must have a default (empty prefix) store const hasDefault = merged.stores.some((s) => s.prefix === ''); if (!hasDefault) { throw new Error( 'No default store configured. At least one --store-dir without a prefix (or a catch-all in config) is required.', ); } return merged; } /** * Parse a --store-dir CLI entry. * * Format: * "/tmp/default" → default store (empty prefix), path = /tmp/default * ":/tmp/default" → default store (empty prefix), path = /tmp/default * "foo:/tmp/foo-store" → prefix = "foo", path = /tmp/foo-store * "foo.bar:/tmp/foobar" → prefix = "foo.bar", path = /tmp/foobar * * Key prefix rules: [a-zA-Z0-9_-]+ separated by dots, no leading/trailing/consecutive dots. * Colons are not valid in key names or directory paths. */ function parseStoreDirEntry(entry: string): StoreMapping { const colonIdx = entry.indexOf(':'); // No colon → entire string is a path → default store if (colonIdx === -1) { return { prefix: '', path: path.resolve(entry), access: 'write' }; } // Colon at position 0 → ":/path" → default store if (colonIdx === 0) { const dirPath = entry.slice(1); if (!dirPath) { throw new Error(`Malformed --store-dir: "${entry}" — nothing after the colon`); } return { prefix: '', path: path.resolve(dirPath), access: 'write' }; } // Characters before and after colon → prefix:path const prefixPart = entry.slice(0, colonIdx); const pathPart = entry.slice(colonIdx + 1); if (!pathPart) { throw new Error(`Malformed --store-dir: "${entry}" — nothing after the colon`); } // Validate key prefix validateKeyPrefix(prefixPart); return { prefix: prefixPart, path: path.resolve(pathPart), access: 'write' }; } /** Key prefix segment: alphanumeric, hyphens, underscores */ const KEY_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/; /** * Validate a key prefix string. * Must be one or more segments of [a-zA-Z0-9_-]+ separated by dots. * No leading/trailing dots, no consecutive dots. */ function validateKeyPrefix(prefix: string): void { if (prefix.startsWith('.')) { throw new Error(`Invalid key prefix "${prefix}": cannot start with a dot`); } if (prefix.endsWith('.')) { throw new Error(`Invalid key prefix "${prefix}": cannot end with a dot`); } if (prefix.includes('..')) { throw new Error(`Invalid key prefix "${prefix}": cannot contain consecutive dots`); } const segments = prefix.split('.'); for (const seg of segments) { if (!seg) { throw new Error(`Invalid key prefix "${prefix}": empty segment`); } if (!KEY_SEGMENT_RE.test(seg)) { throw new Error( `Invalid key prefix "${prefix}": segment "${seg}" contains invalid characters (allowed: letters, digits, hyphens, underscores)`, ); } } } function loadConfigFile(filePath: string): VaultConfig | null { try { const raw = fs.readFileSync(filePath, 'utf-8'); const doc = yaml.load(raw) as Record; if (!doc || !Array.isArray(doc.stores)) return null; return { stores: (doc.stores as unknown[]).map((s: unknown) => { const store = s as Record; // Normalize prefix: strip trailing dots for consistency const rawPrefix = String(store.prefix ?? ''); const normalizedPrefix = rawPrefix.replace(/\.+$/, ''); return { prefix: normalizedPrefix, path: resolvePath(String(store.path ?? DEFAULT_STORE_DIR), path.dirname(filePath)), access: (store.access as AccessLevel) ?? 'write', init: store.init ? { source: resolvePath( String((store.init as Record).source ?? ''), path.dirname(filePath), ), on_exists: String( (store.init as Record).on_exists ?? 'ignore', ) as 'ignore' | 'validate' | 'fix', } : undefined, }; }), }; } catch { return null; } } function resolvePath(p: string, base: string): string { if (path.isAbsolute(p)) return p; return path.resolve(base, p); } function mergeConfigs(configs: VaultConfig[]): VaultConfig { // Later configs override earlier ones by prefix. const byPrefix = new Map(); for (const config of configs) { for (const store of config.stores) { byPrefix.set(store.prefix, store); } } // Sort: longest prefix first, empty string last const stores = Array.from(byPrefix.values()).sort((a, b) => { if (a.prefix === '') return 1; if (b.prefix === '') return -1; return b.prefix.length - a.prefix.length; }); return { stores }; } // ───────────────────────────────────────────────────────────────────────────── // STORE RESOLUTION // ───────────────────────────────────────────────────────────────────────────── export interface ResolvedStore { mapping: StoreMapping; dir: string; // absolute path to the data directory relativeKey: string; // key with the prefix stripped } /** * Resolve a dot-separated key to its data directory and relative path. * Matches the longest prefix first. Prefixes are stored WITHOUT trailing dots. * * A prefix "foo.bar" matches: * - key "foo.bar" exactly * - key "foo.bar.baz" (starts with "foo.bar.") * * A prefix "" (empty) is the catch-all default that matches everything. */ export function resolveStore(config: VaultConfig, key: string): ResolvedStore { for (const mapping of config.stores) { if (mapping.prefix === '') { // Catch-all default — always matches return { mapping, dir: mapping.path, relativeKey: key }; } if (key === mapping.prefix || key.startsWith(mapping.prefix + '.')) { // Strip the prefix and the dot separator to get the relative key const relativeKey = key === mapping.prefix ? '' : key.slice(mapping.prefix.length + 1); return { mapping, dir: mapping.path, relativeKey }; } } // Should never happen if there's a catch-all, but fallback const fallback = config.stores[config.stores.length - 1] ?? { prefix: '', path: DEFAULT_STORE_DIR, access: 'write' as AccessLevel, }; return { mapping: fallback, dir: fallback.path, relativeKey: key }; } /** * Convert a dot-separated key to a filesystem path within a store directory. */ export function keyToPath(storeDir: string, relativeKey: string): string { if (!relativeKey) return storeDir; const segments = relativeKey.split('.'); return path.join(storeDir, ...segments); } /** * Get the full filesystem path for a key's value file. */ export function keyValuePath(config: VaultConfig, key: string): string { const resolved = resolveStore(config, key); return path.join(keyToPath(resolved.dir, resolved.relativeKey), 'value'); } /** * Get the full filesystem path for a key's meta.yaml file. */ export function keyMetaPath(config: VaultConfig, key: string): string { const resolved = resolveStore(config, key); return path.join(keyToPath(resolved.dir, resolved.relativeKey), 'meta.yaml'); } // ───────────────────────────────────────────────────────────────────────────── // METADATA // ───────────────────────────────────────────────────────────────────────────── /** * Load store-level defaults from .store.yaml in the data directory. */ export function loadStoreDefaults(storeDir: string): StoreDefaults { const storeYamlPath = path.join(storeDir, '.store.yaml'); if (!fs.existsSync(storeYamlPath)) return { ...BUILTIN_DEFAULTS }; try { const doc = yaml.load(fs.readFileSync(storeYamlPath, 'utf-8')) as StoreYaml | null; return { ...BUILTIN_DEFAULTS, ...(doc?.defaults ?? {}) }; } catch { return { ...BUILTIN_DEFAULTS }; } } /** * Load per-key metadata, merged with store defaults. */ export function loadKeyMeta(config: VaultConfig, key: string): KeyMeta { const resolved = resolveStore(config, key); const defaults = loadStoreDefaults(resolved.dir); const metaPath = path.join(keyToPath(resolved.dir, resolved.relativeKey), 'meta.yaml'); let keyMeta: KeyMeta = {}; if (fs.existsSync(metaPath)) { try { keyMeta = (yaml.load(fs.readFileSync(metaPath, 'utf-8')) as KeyMeta) ?? {}; } catch { // ignore parse errors, use defaults } } return { literal: keyMeta.literal ?? defaults.literal ?? BUILTIN_DEFAULTS.literal, readonly: keyMeta.readonly ?? defaults.readonly ?? BUILTIN_DEFAULTS.readonly, write_once: keyMeta.write_once ?? defaults.write_once ?? BUILTIN_DEFAULTS.write_once, type: keyMeta.type ?? defaults.type ?? BUILTIN_DEFAULTS.type, template: keyMeta.template ?? defaults.template ?? BUILTIN_DEFAULTS.template, on_absent: keyMeta.on_absent ?? defaults.on_absent ?? BUILTIN_DEFAULTS.on_absent, owner: keyMeta.owner, validate: keyMeta.validate, description: keyMeta.description, }; } /** * Save per-key metadata. */ export function saveKeyMeta(config: VaultConfig, key: string, meta: KeyMeta): void { const resolved = resolveStore(config, key); const keyDir = keyToPath(resolved.dir, resolved.relativeKey); fs.mkdirSync(keyDir, { recursive: true }); const metaPath = path.join(keyDir, 'meta.yaml'); atomicWrite(metaPath, yaml.dump(meta, { lineWidth: -1 })); } /** * Load template schema (schema.yaml) for a template key. */ export function loadSchema(config: VaultConfig, key: string): TemplateSchema | null { const resolved = resolveStore(config, key); const schemaPath = path.join(keyToPath(resolved.dir, resolved.relativeKey), 'schema.yaml'); if (!fs.existsSync(schemaPath)) return null; try { const doc = yaml.load(fs.readFileSync(schemaPath, 'utf-8')) as TemplateSchema | null; return doc ?? null; } catch { return null; } } // ───────────────────────────────────────────────────────────────────────────── // VALUE I/O // ───────────────────────────────────────────────────────────────────────────── /** * Read a raw value from the store. Returns null if not found. */ export function readValue(config: VaultConfig, key: string): string | null { const valuePath = keyValuePath(config, key); if (!fs.existsSync(valuePath)) return null; return fs.readFileSync(valuePath, 'utf-8'); } /** * Write a raw value to the store with atomic write. */ export function writeValue(config: VaultConfig, key: string, value: string): void { const resolved = resolveStore(config, key); const keyDir = keyToPath(resolved.dir, resolved.relativeKey); fs.mkdirSync(keyDir, { recursive: true }); const valuePath = path.join(keyDir, 'value'); atomicWrite(valuePath, value); } /** * Check if a key has a value file. */ export function hasValue(config: VaultConfig, key: string): boolean { return fs.existsSync(keyValuePath(config, key)); } /** * Delete a key's value and meta files. Returns true if deleted. */ export function deleteValue(config: VaultConfig, key: string): boolean { const resolved = resolveStore(config, key); const keyDir = keyToPath(resolved.dir, resolved.relativeKey); let deleted = false; const valuePath = path.join(keyDir, 'value'); if (fs.existsSync(valuePath)) { fs.unlinkSync(valuePath); deleted = true; } const metaPath = path.join(keyDir, 'meta.yaml'); if (fs.existsSync(metaPath)) { fs.unlinkSync(metaPath); } // Clean up empty parent directories cleanEmptyDirs(keyDir, resolved.dir); return deleted; } function cleanEmptyDirs(dir: string, stopAt: string): void { const resolved = path.resolve(dir); const stop = path.resolve(stopAt); if (resolved === stop || !resolved.startsWith(stop)) return; try { const entries = fs.readdirSync(resolved); if (entries.length === 0) { fs.rmdirSync(resolved); cleanEmptyDirs(path.dirname(resolved), stopAt); } } catch { // ignore } } // ───────────────────────────────────────────────────────────────────────────── // ATOMIC WRITES // ───────────────────────────────────────────────────────────────────────────── export function atomicWrite(filePath: string, content: string): void { const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); const tmpPath = filePath + '.tmp.' + process.pid; fs.writeFileSync(tmpPath, content, 'utf-8'); fs.renameSync(tmpPath, filePath); } // ───────────────────────────────────────────────────────────────────────────── // VALIDATION // ───────────────────────────────────────────────────────────────────────────── const TYPE_VALIDATORS: Record string | null> = { string: () => null, nonempty: (v) => (v.length === 0 ? 'Value must not be empty' : null), number: (v) => (isNaN(Number(v)) ? `"${v}" is not a valid number` : null), integer: (v) => (!Number.isInteger(Number(v)) || isNaN(Number(v)) ? `"${v}" is not a valid integer` : null), positive_integer: (v) => { const n = Number(v); if (isNaN(n) || !Number.isInteger(n)) return `"${v}" is not a valid integer`; if (n <= 0) return `"${v}" is not a positive integer`; return null; }, boolean: (v) => (v !== 'true' && v !== 'false' ? `"${v}" must be "true" or "false"` : null), url: (v) => (v.startsWith('http://') || v.startsWith('https://') ? null : `"${v}" is not a valid URL`), hex: (v) => (/^[0-9a-fA-F]+$/.test(v) ? null : `"${v}" is not a valid hex string`), }; /** * Validate a value against a key's metadata (type + custom regex). * Returns null on success, error message on failure. */ export function validateValue(value: string, meta: KeyMeta): string | null { // Type validation const typeCheck = meta.type ? TYPE_VALIDATORS[meta.type] : undefined; if (typeCheck) { const err = typeCheck(value); if (err) return err; } // Custom regex validation if (meta.validate?.regex) { const re = new RegExp(meta.validate.regex); if (!re.test(value)) { return meta.validate.message ?? `Value does not match pattern: ${meta.validate.regex}`; } } return null; } // ───────────────────────────────────────────────────────────────────────────── // NUNJUCKS ENVIRONMENT // ───────────────────────────────────────────────────────────────────────────── /** * Create a Nunjucks environment configured for vault template evaluation. * Variables are passed as a flat context object. */ export function createNunjucksEnv(): nunjucks.Environment { const env = new nunjucks.Environment(null, { autoescape: false, // We're not generating HTML throwOnUndefined: false, // We handle missing vars via | default() }); // Add custom filters env.addFilter('max', function (...args: unknown[]) { // Support both {{ [a, b] | max }} and {{ a | max(b) }} const nums = args.flat().map(Number).filter((n) => !isNaN(n)); return Math.max(...nums); }); env.addFilter('min', function (...args: unknown[]) { const nums = args.flat().map(Number).filter((n) => !isNaN(n)); return Math.min(...nums); }); env.addFilter('ceil', function (val: unknown) { return Math.ceil(Number(val)); }); env.addFilter('floor', function (val: unknown) { return Math.floor(Number(val)); }); env.addFilter('abs', function (val: unknown) { return Math.abs(Number(val)); }); return env; } /** * Evaluate a Nunjucks template string with the given variables. */ export function evaluateTemplate( template: string, variables: Record, ): string { const env = createNunjucksEnv(); return env.renderString(template, variables); } // ───────────────────────────────────────────────────────────────────────────── // TEMPLATE VALUE RESOLUTION (lazy compute-on-read) // ───────────────────────────────────────────────────────────────────────────── /** * Get a value, evaluating Nunjucks templates lazily if the key is marked * as template: true. Recursively resolves dependencies. */ export function getValue( config: VaultConfig, key: string, _visited?: Set, ): GetResult { const visited = _visited ?? new Set(); if (visited.has(key)) { return { key, value: '', found: false, metadata: undefined, evaluated: false }; } visited.add(key); const rawValue = readValue(config, key); if (rawValue === null) { return { key, value: '', found: false }; } const meta = loadKeyMeta(config, key); if (!meta.template) { return { key, value: rawValue, found: true, metadata: meta, evaluated: false }; } // Collect all vault variables accessible from this key's store context const variables = collectVariables(config, key, visited); try { const evaluated = evaluateTemplate(rawValue, variables).trim(); return { key, value: evaluated, found: true, metadata: meta, evaluated: true }; } catch (err) { return { key, value: `TEMPLATE_ERROR: ${err instanceof Error ? err.message : String(err)}`, found: true, metadata: meta, evaluated: true, }; } } /** * Collect all resolvable variables for template evaluation. * Walks all stores and builds a flat variable map. * * IMPORTANT: Only collects RAW values (non-template keys). Template keys * are NOT recursively evaluated here to avoid infinite recursion. If a * template depends on another template's output, the dependency must be * a non-template key. */ function collectVariables( config: VaultConfig, _contextKey: string, _visited: Set, ): Record { const vars: Record = {}; // Walk all stores and collect non-template keys with raw values for (const mapping of config.stores) { if (!fs.existsSync(mapping.path)) continue; const keys = listKeysRecursive(mapping.path, mapping.prefix); for (const k of keys) { const meta = loadKeyMeta(config, k); // Skip template keys to avoid recursive evaluation if (meta.template) continue; const rawValue = readValue(config, k); if (rawValue !== null) { // Store by full key (dots replaced with underscores for Nunjucks compatibility) const nunjucksKey = k.replace(/\./g, '_'); vars[nunjucksKey] = rawValue; // Also store by last segment (short name) if not already set const shortName = k.split('.').pop()!; if (!(shortName in vars)) { vars[shortName] = rawValue; } } } } return vars; } // ───────────────────────────────────────────────────────────────────────────── // KEY LISTING // ───────────────────────────────────────────────────────────────────────────── /** * List all keys with values under a prefix, within a store directory. * Returns fully-qualified dot-separated keys. */ export function listKeysRecursive(storeDir: string, storePrefix: string): string[] { const keys: string[] = []; function walk(dir: string, keyParts: string[]): void { if (!fs.existsSync(dir)) return; const entries = fs.readdirSync(dir, { withFileTypes: true }); // Check if this directory has a value file if (entries.some((e) => e.isFile() && e.name === 'value') && keyParts.length > 0) { const relativePart = keyParts.join('.'); const fullKey = storePrefix ? storePrefix + '.' + relativePart : relativePart; keys.push(fullKey); } // Recurse into subdirectories (skip dot-prefixed like .store.yaml) for (const entry of entries) { if (entry.isDirectory() && !entry.name.startsWith('.')) { walk(path.join(dir, entry.name), [...keyParts, entry.name]); } } } walk(storeDir, []); return keys; } /** * List immediate child keys under a prefix (one level deep). */ export function listKeysImmediate( config: VaultConfig, prefix: string, ): string[] { const resolved = resolveStore(config, prefix || '_root_'); // For empty prefix targeting catch-all, we need to handle differently const relKey = prefix ? resolved.relativeKey : ''; const baseDir = keyToPath(resolved.dir, relKey); if (!fs.existsSync(baseDir)) return []; const keys: string[] = []; const entries = fs.readdirSync(baseDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && !entry.name.startsWith('.')) { const childKey = prefix ? `${prefix}.${entry.name}` : entry.name; const childValuePath = path.join(baseDir, entry.name, 'value'); if (fs.existsSync(childValuePath)) { keys.push(childKey); } } } return keys; } /** * List all keys with values under a prefix across all matching stores. */ export function listAllKeys(config: VaultConfig, prefix: string, recursive: boolean): string[] { const allKeys = new Set(); for (const mapping of config.stores) { // Check if this store could contain keys under the prefix if (prefix && mapping.prefix && !prefix.startsWith(mapping.prefix) && !mapping.prefix.startsWith(prefix)) { continue; } if (!fs.existsSync(mapping.path)) continue; const storeKeys = listKeysRecursive(mapping.path, mapping.prefix); for (const k of storeKeys) { if (!prefix || k.startsWith(prefix)) { if (recursive || k.split('.').length <= (prefix ? prefix.split('.').length + 1 : 1)) { allKeys.add(k); } } } } return Array.from(allKeys).sort(); } // ───────────────────────────────────────────────────────────────────────────── // CLI HELPERS // ───────────────────────────────────────────────────────────────────────────── /** * Parse common CLI flags from process.argv. * * --store-dir can be specified multiple times: * --store-dir /tmp/default (default store, no prefix) * --store-dir foo.bar:/tmp/foobar (prefix "foo.bar" → /tmp/foobar) * --store-dir :/tmp/also-default (explicit default) */ export function parseCommonFlags(argv: string[]): { config?: string; storeDirs: string[]; rest: string[]; } { const rest: string[] = []; let config: string | undefined; const storeDirs: string[] = []; const args = argv.slice(2); for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--config': config = args[++i]; break; case '--store-dir': storeDirs.push(args[++i]); break; default: rest.push(args[i]); break; } } return { config, storeDirs, rest }; } /** * Extract a named flag value from an args array. */ export function getFlag(args: string[], flag: string): string | undefined { const idx = args.indexOf(flag); if (idx === -1 || idx >= args.length - 1) return undefined; return args[idx + 1]; } /** * Check if a boolean flag is present. */ export function hasFlag(args: string[], flag: string): boolean { return args.includes(flag); } /** * Write JSON to stdout. */ export function output(data: unknown): void { process.stdout.write(JSON.stringify(data, null, 2) + '\n'); } /** * Write a log message to stderr. */ export function log(msg: string): void { process.stderr.write(msg + '\n'); }