Files
cleveragents-core/.opencode/skills/templating-vault/scripts/api.ts
freemo 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
build: auto opencode agents rewritten
2026-04-27 12:49:08 -04:00

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');
}