1885990081
CI / build (push) Waiting to run
CI / docker (push) Blocked by required conditions
CI / helm (push) Waiting to run
CI / push-validation (push) Waiting to run
CI / status-check (push) Blocked by required conditions
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / security (push) Waiting to run
CI / quality (push) Waiting to run
CI / unit_tests (push) Waiting to run
CI / integration_tests (push) Waiting to run
CI / e2e_tests (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / benchmark-publish (push) Waiting to run
860 lines
30 KiB
TypeScript
860 lines
30 KiB
TypeScript
// =============================================================================
|
|
// 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<string, SchemaVariable>;
|
|
}
|
|
|
|
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<string, unknown>;
|
|
if (!doc || !Array.isArray(doc.stores)) return null;
|
|
return {
|
|
stores: (doc.stores as unknown[]).map((s: unknown) => {
|
|
const store = s as Record<string, unknown>;
|
|
// 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<string, unknown>).source ?? ''),
|
|
path.dirname(filePath),
|
|
),
|
|
on_exists: String(
|
|
(store.init as Record<string, unknown>).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<string, StoreMapping>();
|
|
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, (val: string) => 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, unknown>,
|
|
): 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<string>,
|
|
): GetResult {
|
|
const visited = _visited ?? new Set<string>();
|
|
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<string>,
|
|
): Record<string, unknown> {
|
|
const vars: Record<string, unknown> = {};
|
|
|
|
// 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<string>();
|
|
|
|
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');
|
|
}
|