From 7989101e893b46a3b19ae92374512b1e63a0d47f Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 9 Apr 2026 13:14:11 -0700 Subject: [PATCH 1/8] add caching system, random value generators, and cache() resolver First-class caching for varlock with encrypted storage: - cache() resolver function wraps any resolver to persist values across invocations. Supports ttl (defaults to forever), custom key, and auto-invalidation when the wrapped resolver expression changes. - Encrypted JSON cache store at ~/.config/varlock/cache/ using the existing local-encrypt system. Values are JSON-serialized to preserve types (numbers, booleans, objects). - AsyncLocalStorage-based resolution context threads cache store and current item to resolvers without modifying the Resolver class. - Plugin cache API via plugin.cache.get/set with automatic plugin:name:key namespacing. Random value generator resolvers: - randomInt(min?, max?) - cryptographically secure random integer - randomFloat(min?, max?, precision=N) - random float - randomUuid() - UUID v4 - randomHex(bytes?) - random hex string - randomString(length?, charset=S) - random alphanumeric string CLI additions: - varlock cache / varlock cache clear commands - --clear-cache and --skip-cache flags on load/run/printenv - Cache hit indicators in varlock load pretty output - Cache section in varlock explain output Documentation for all new functions and CLI commands. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/bright-cache-dance.md | 5 + .../content/docs/reference/cli-commands.mdx | 30 ++ .../src/content/docs/reference/functions.mdx | 125 +++++- packages/varlock/src/cli/cli-executable.ts | 2 + .../varlock/src/cli/commands/cache.command.ts | 78 ++++ .../src/cli/commands/explain.command.ts | 17 +- .../varlock/src/cli/commands/load.command.ts | 10 + .../src/cli/commands/printenv.command.ts | 10 + .../varlock/src/cli/commands/run.command.ts | 10 + .../varlock/src/env-graph/lib/config-item.ts | 32 +- .../varlock/src/env-graph/lib/env-graph.ts | 23 +- packages/varlock/src/env-graph/lib/loader.ts | 21 ++ packages/varlock/src/env-graph/lib/plugins.ts | 24 ++ .../src/env-graph/lib/resolution-context.ts | 36 ++ .../varlock/src/env-graph/lib/resolver.ts | 280 ++++++++++++++ .../src/env-graph/test/cache-resolver.test.ts | 357 ++++++++++++++++++ .../env-graph/test/random-resolvers.test.ts | 156 ++++++++ .../varlock/src/lib/cache/cache-store.test.ts | 214 +++++++++++ packages/varlock/src/lib/cache/cache-store.ts | 222 +++++++++++ packages/varlock/src/lib/cache/index.ts | 3 + .../src/lib/cache/plugin-cache-accessor.ts | 42 +++ .../varlock/src/lib/cache/ttl-parser.test.ts | 70 ++++ packages/varlock/src/lib/cache/ttl-parser.ts | 58 +++ packages/varlock/src/lib/formatting.ts | 18 + packages/varlock/src/lib/load-graph.ts | 6 + packages/varlock/src/plugin-lib.ts | 1 + 26 files changed, 1846 insertions(+), 4 deletions(-) create mode 100644 .changeset/bright-cache-dance.md create mode 100644 packages/varlock/src/cli/commands/cache.command.ts create mode 100644 packages/varlock/src/env-graph/lib/resolution-context.ts create mode 100644 packages/varlock/src/env-graph/test/cache-resolver.test.ts create mode 100644 packages/varlock/src/env-graph/test/random-resolvers.test.ts create mode 100644 packages/varlock/src/lib/cache/cache-store.test.ts create mode 100644 packages/varlock/src/lib/cache/cache-store.ts create mode 100644 packages/varlock/src/lib/cache/index.ts create mode 100644 packages/varlock/src/lib/cache/plugin-cache-accessor.ts create mode 100644 packages/varlock/src/lib/cache/ttl-parser.test.ts create mode 100644 packages/varlock/src/lib/cache/ttl-parser.ts diff --git a/.changeset/bright-cache-dance.md b/.changeset/bright-cache-dance.md new file mode 100644 index 00000000..6de15ee0 --- /dev/null +++ b/.changeset/bright-cache-dance.md @@ -0,0 +1,5 @@ +--- +"varlock": minor +--- + +add caching system with cache() resolver, random value generators, and plugin cache API diff --git a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx index e7dc7ca3..a16d6372 100644 --- a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx +++ b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx @@ -77,6 +77,8 @@ varlock load [options] - `--show-all`: Shows all items, not just failing ones, when validation is failing - `--env`: Set the default environment flag (e.g., `--env production`), only useful if not using `@currentEnv` in `.env.schema` - `--path` / `-p`: Path to a specific `.env` file or directory to use as the entry point (overrides `varlock.loadPath` in `package.json`) +- `--clear-cache`: Clear the cache and re-resolve all values (writes new values back to cache) +- `--skip-cache`: Skip cache entirely for this invocation (no reads or writes) **Examples:** ```bash @@ -120,6 +122,8 @@ varlock run -- **Options:** - `--no-redact-stdout`: Disable stdout/stderr redaction to preserve TTY detection for interactive tools - `--path` / `-p`: Path to a specific `.env` file or directory to use as the entry point +- `--clear-cache`: Clear the cache and re-resolve all values +- `--skip-cache`: Skip cache entirely for this invocation **Examples:** ```bash @@ -331,6 +335,32 @@ Use `varlock lock` when stepping away from your machine to ensure the next perso +
+### `varlock cache` ||cache|| + +Manage the encrypted value cache used by [`cache()`](/reference/functions/#cache) and plugin authors. Shows cache status by default, or clears cached entries. + +```bash +varlock cache [clear] [options] +``` + +**Options:** +- `--plugin `: When clearing, only remove entries for a specific plugin + +**Examples:** +```bash +# Show cache status (entry counts, file size, location) +varlock cache + +# Clear all cache entries +varlock cache clear + +# Clear cache for a specific plugin only +varlock cache clear --plugin 1password +``` + +
+
### `varlock typegen` ||typegen|| diff --git a/packages/varlock-website/src/content/docs/reference/functions.mdx b/packages/varlock-website/src/content/docs/reference/functions.mdx index bee8d4cb..86dc290e 100644 --- a/packages/varlock-website/src/content/docs/reference/functions.mdx +++ b/packages/varlock-website/src/content/docs/reference/functions.mdx @@ -22,7 +22,7 @@ CONFIG=exec(`aws ssm get-parameter --name "/config/${APP_ENV}" --with-decryption ``` -There are built-in utility functions, a built-in `varlock()` function for device-local encryption, and plugin-provided resolver functions that can fetch data from external providers. See the [Plugins guide](/guides/plugins/) for more information on plugin-provided functions. +There are built-in utility functions, [random value generators](#random-value-generators), a [`cache()`](#cache) function for persisting values across runs, a built-in `varlock()` function for device-local encryption, and plugin-provided resolver functions that can fetch data from external providers. See the [Plugins guide](/guides/plugins/) for more information on plugin-provided functions.
@@ -219,6 +219,129 @@ API_URL=if(isEmpty($CUSTOM_API_URL), "https://api.default.com", $CUSTOM_API_URL) ```
+## Random value generators + +These functions generate random values using cryptographically secure randomness (`node:crypto`). They are typically used with [`cache()`](#cache) to generate a value once and persist it across runs. + +
+### `randomInt()` + +Generates a random integer. By default generates between `0` and `2,147,483,647` (int32 max). + +- With 1 arg: generates between `0` and `max` (inclusive) +- With 2 args: generates between `min` and `max` (inclusive) + +```env-spec "randomInt" +# Random port between 3000 and 4000 +DEV_PORT=cache(randomInt(3000, 4000)) + +# Random integer up to 1000 +SEED=cache(randomInt(1000)) + +# Random integer with default range +BIG_NUMBER=cache(randomInt()) +``` +
+ +
+### `randomFloat()` + +Generates a random floating-point number. By default generates between `0` and `1` with 2 decimal places. + +- With 1 arg: generates between `0` and `max` +- With 2 args: generates between `min` and `max` +- `precision=N` option controls decimal places (default: 2) + +```env-spec "randomFloat" +# Random float between 0 and 1 (default) +RATE=cache(randomFloat()) + +# Random float between 10 and 20 with 4 decimal places +THRESHOLD=cache(randomFloat(10, 20, precision=4)) +``` +
+ +
+### `randomUuid()` + +Generates a random UUID v4. + +```env-spec "randomUuid" +# Unique identifier for this environment +INSTANCE_ID=cache(randomUuid()) +``` +
+ +
+### `randomHex()` + +Generates a random hexadecimal string. Argument is the byte length (each byte = 2 hex characters). Default is `16` bytes (32 hex chars). + +```env-spec "randomHex" +# 64-character hex string (32 bytes) +ENCRYPTION_KEY=cache(randomHex(32)) + @sensitive + +# 32-character hex string (default 16 bytes) +SESSION_SECRET=cache(randomHex()) + @sensitive +``` +
+ +
+### `randomString()` + +Generates a random alphanumeric string. Default length is `16` characters using `A-Za-z0-9`. + +- First arg: character length (default: 16) +- `charset=S` option: custom character set to draw from + +```env-spec "randomString" +# 32-character alphanumeric string +API_SECRET=cache(randomString(32)) + @sensitive + +# 8-character string from custom charset +PIN_CODE=cache(randomString(8, charset="0123456789")) +``` +
+ +## Caching + +
+### `cache()` + +Wraps any resolver to cache its result across invocations. Cached values are encrypted at rest using varlock's [device-local encryption](/guides/secrets/#local-encryption). + +- First arg: the resolver to cache +- `ttl=D` option: how long to cache (default: forever). Supports `s`, `m`, `h`, `d`, `w` suffixes, or `0` for forever. +- `key=S` option: use an explicit cache key instead of the auto-generated one. Useful when the same cached value should be shared across files or when you want a stable key that doesn't change with resolver edits. + +The cache automatically invalidates when you change the wrapped resolver expression (unless using a custom `key`). + +```env-spec "cache" +# Cache a random UUID forever (until manually cleared) +INSTANCE_ID=cache(randomUuid()) + +# Cache an API token for 1 hour +AUTH_TOKEN=cache(exec(`get-token.sh`), ttl="1h") + +# Cache for 30 minutes +TEMP_KEY=cache(randomHex(32), ttl="30m") + +# Use an explicit cache key (shared across files/projects) +SHARED_TOKEN=cache(exec(`fetch-org-token.sh`), ttl="1d", key="org-auth-token") +``` + +Use the [`varlock cache`](/reference/cli-commands/#cache) CLI command to view cache status or clear cached values. + +Use `--clear-cache` or `--skip-cache` flags on `varlock load` / `varlock run` to control caching behavior for a single invocation. + +:::tip +Plugin authors can also use the cache API via `plugin.cache.get()` / `plugin.cache.set()` to cache expensive API calls. See the [Plugins guide](/guides/plugins/) for more information. +::: +
+
### `varlock()` diff --git a/packages/varlock/src/cli/cli-executable.ts b/packages/varlock/src/cli/cli-executable.ts index 9bc376bf..99ee2ed5 100644 --- a/packages/varlock/src/cli/cli-executable.ts +++ b/packages/varlock/src/cli/cli-executable.ts @@ -25,6 +25,7 @@ import { commandSpec as explainCommandSpec } from './commands/explain.command'; import { commandSpec as scanCommandSpec } from './commands/scan.command'; import { commandSpec as typegenCommandSpec } from './commands/typegen.command'; import { commandSpec as installPluginCommandSpec } from './commands/install-plugin.command'; +import { commandSpec as cacheCommandSpec } from './commands/cache.command'; // import { commandSpec as loginCommandSpec } from './commands/login.command'; // import { commandSpec as pluginCommandSpec } from './commands/plugin.command'; @@ -64,6 +65,7 @@ subCommands.set('telemetry', buildLazyCommand(telemetryCommandSpec, async () => subCommands.set('scan', buildLazyCommand(scanCommandSpec, async () => await import('./commands/scan.command'))); subCommands.set('typegen', buildLazyCommand(typegenCommandSpec, async () => await import('./commands/typegen.command'))); subCommands.set('install-plugin', buildLazyCommand(installPluginCommandSpec, async () => await import('./commands/install-plugin.command'))); +subCommands.set('cache', buildLazyCommand(cacheCommandSpec, async () => await import('./commands/cache.command'))); // subCommands.set('login', buildLazyCommand(loginCommandSpec, async () => await import('./commands/login.command'))); // subCommands.set('plugin', buildLazyCommand(pluginCommandSpec, async () => await import('./commands/plugin.command'))); diff --git a/packages/varlock/src/cli/commands/cache.command.ts b/packages/varlock/src/cli/commands/cache.command.ts new file mode 100644 index 00000000..14ab1242 --- /dev/null +++ b/packages/varlock/src/cli/commands/cache.command.ts @@ -0,0 +1,78 @@ +import fs from 'node:fs'; +import ansis from 'ansis'; +import { define } from 'gunshi'; + +import { CacheStore } from '../../lib/cache'; +import * as localEncrypt from '../../lib/local-encrypt'; +import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; + +export const commandSpec = define({ + name: 'cache', + description: 'Manage the varlock cache', + args: { + plugin: { + type: 'string', + description: 'Clear cache for a specific plugin only', + }, + }, + examples: ` +Manage the encrypted value cache used by cache() and plugin authors. + +Examples: + varlock cache # Show cache status + varlock cache clear # Clear all cache entries + varlock cache clear --plugin 1password # Clear cache for specific plugin +`.trim(), +}); + +export const commandFn: TypedGunshiCommandFn = async (ctx) => { + const positionals = (ctx.positionals ?? []).slice(ctx.commandPath?.length ?? 0); + const action = positionals[0] ?? 'status'; + + if (!localEncrypt.keyExists()) { + console.log(ansis.gray(' No encryption key found — cache is not active.')); + return; + } + + const store = new CacheStore(); + + if (action === 'status') { + const stats = store.getStats(); + const filePath = store.getFilePath(); + + console.log(''); + console.log(ansis.bold(' Cache status')); + console.log(` ${ansis.gray('File:')} ${filePath}`); + + if (fs.existsSync(filePath)) { + const fileSize = fs.statSync(filePath).size; + const sizeStr = fileSize < 1024 ? `${fileSize}B` : `${(fileSize / 1024).toFixed(1)}KB`; + console.log(` ${ansis.gray('Size:')} ${sizeStr}`); + } + + console.log(` ${ansis.gray('Entries:')} ${stats.total} (${stats.expired} expired)`); + + if (Object.keys(stats.byPrefix).length > 0) { + console.log(''); + console.log(ansis.bold(' Entries by type')); + for (const [prefix, count] of Object.entries(stats.byPrefix)) { + console.log(` ${ansis.cyan(prefix)}: ${count}`); + } + } + console.log(''); + } else if (action === 'clear') { + const pluginName = ctx.values.plugin; + let count: number; + + if (pluginName) { + count = store.clearByPrefix(`plugin:${pluginName}:`); + console.log(` Cleared ${count} cache entries for plugin "${pluginName}"`); + } else { + count = store.clearAll(); + console.log(` Cleared ${count} cache entries`); + } + } else { + console.log(ansis.red(` Unknown action: ${action}`)); + console.log(' Available actions: status, clear'); + } +}; diff --git a/packages/varlock/src/cli/commands/explain.command.ts b/packages/varlock/src/cli/commands/explain.command.ts index 9126b3bf..61f1017f 100644 --- a/packages/varlock/src/cli/commands/explain.command.ts +++ b/packages/varlock/src/cli/commands/explain.command.ts @@ -3,7 +3,7 @@ import { define } from 'gunshi'; import { gracefulExit } from 'exit-hook'; import { loadVarlockEnvGraph } from '../../lib/load-graph'; -import { formattedValue } from '../../lib/formatting'; +import { formattedValue, formatTimeAgo } from '../../lib/formatting'; import { redactString } from '../../runtime/lib/redaction'; import { checkForSchemaErrors, checkForNoEnvFiles, @@ -148,6 +148,21 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = } } + // Cache info + if (item.isCached || item.isCacheHit) { + const cacheTtl = item.cacheTtl; + const ttlDisplay = cacheTtl !== undefined ? String(cacheTtl) : 'forever'; + console.log(''); + console.log(ansis.bold(' Cache')); + console.log(` ${ansis.gray('TTL:')} ${ttlDisplay}`); + if (item.isCacheHit) { + const oldest = Math.min(...item._cacheHits.map((h) => h.cachedAt)); + console.log(` ${ansis.blue('Status:')} hit (cached ${formatTimeAgo(oldest)})`); + } else { + console.log(` ${ansis.gray('Status:')} miss (freshly resolved)`); + } + } + // All definitions const defs = item.defs; if (defs.length) { diff --git a/packages/varlock/src/cli/commands/load.command.ts b/packages/varlock/src/cli/commands/load.command.ts index 2c64d50f..1e60354b 100644 --- a/packages/varlock/src/cli/commands/load.command.ts +++ b/packages/varlock/src/cli/commands/load.command.ts @@ -36,6 +36,14 @@ export const commandSpec = define({ short: 'p', description: 'Path to a specific .env file or directory to use as the entry point', }, + 'clear-cache': { + type: 'boolean', + description: 'Clear cache and re-resolve all values', + }, + 'skip-cache': { + type: 'boolean', + description: 'Skip cache entirely for this invocation', + }, }, examples: ` Loads and validates environment variables according to your .env files, and prints the results. @@ -68,6 +76,8 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = const envGraph = await loadVarlockEnvGraph({ currentEnvFallback: ctx.values.env, entryFilePath: ctx.values.path, + clearCache: ctx.values['clear-cache'], + skipCache: ctx.values['skip-cache'], }); // For json-full, always output the serialized graph — it includes `errors` and diff --git a/packages/varlock/src/cli/commands/printenv.command.ts b/packages/varlock/src/cli/commands/printenv.command.ts index 72f4bb3a..129278bd 100644 --- a/packages/varlock/src/cli/commands/printenv.command.ts +++ b/packages/varlock/src/cli/commands/printenv.command.ts @@ -15,6 +15,14 @@ export const commandSpec = define({ short: 'p', description: 'Path to a specific .env file or directory (with trailing slash) to use as the entry point', }, + 'clear-cache': { + type: 'boolean', + description: 'Clear cache and re-resolve all values', + }, + 'skip-cache': { + type: 'boolean', + description: 'Skip cache entirely for this invocation', + }, }, examples: ` Prints the resolved value of a single environment variable. @@ -46,6 +54,8 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = const envGraph = await loadVarlockEnvGraph({ entryFilePath: ctx.values.path, + clearCache: ctx.values['clear-cache'], + skipCache: ctx.values['skip-cache'], }); checkForSchemaErrors(envGraph); diff --git a/packages/varlock/src/cli/commands/run.command.ts b/packages/varlock/src/cli/commands/run.command.ts index 59081514..83ac2803 100644 --- a/packages/varlock/src/cli/commands/run.command.ts +++ b/packages/varlock/src/cli/commands/run.command.ts @@ -25,6 +25,14 @@ export const commandSpec = define({ short: 'p', description: 'Path to a specific .env file or directory to use as the entry point', }, + 'clear-cache': { + type: 'boolean', + description: 'Clear cache and re-resolve all values', + }, + 'skip-cache': { + type: 'boolean', + description: 'Skip cache entirely for this invocation', + }, }, examples: ` Executes a command in a child process, injecting your resolved and validated environment @@ -73,6 +81,8 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = const envGraph = await loadVarlockEnvGraph({ entryFilePath: ctx.values.path, + clearCache: ctx.values['clear-cache'], + skipCache: ctx.values['skip-cache'], }); checkForSchemaErrors(envGraph); checkForNoEnvFiles(envGraph); diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index 9f381e43..b4975a5d 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -9,10 +9,11 @@ import { CoercionError, EmptyRequiredValueError, ResolutionError, SchemaError, ValidationError, } from './errors'; +import type { CacheHitInfo } from './resolution-context'; import { EnvGraphDataSource } from './data-source'; import { - convertParsedValueToResolvers, type ResolvedValue, type Resolver, StaticValueResolver, + convertParsedValueToResolvers, type ResolvedValue, Resolver, StaticValueResolver, } from './resolver'; import { ItemDecoratorInstance } from './decorators'; @@ -35,6 +36,35 @@ export class ConfigItem { /** Whether this is a builtin VARLOCK_* variable */ isBuiltin?: boolean; + /** Cache hits recorded during resolution (rolled up from potentially multiple cache() resolvers) */ + _cacheHits: Array = []; + + /** Whether any value was served from cache */ + get isCacheHit() { return this._cacheHits.length > 0; } + + /** Whether this item uses cache(). */ + get isCached(): boolean { + return this._findCacheResolver(this.valueResolver) !== undefined; + } + + /** TTL string from the cache() resolver (for display in explain command). undefined = forever. */ + get cacheTtl(): string | number | undefined { + const cacheResolver = this._findCacheResolver(this.valueResolver); + if (!cacheResolver) return undefined; + const ttlResolver = cacheResolver.objArgs?.ttl; + return ttlResolver?.staticValue as string | number | undefined; + } + + private _findCacheResolver(resolver?: Resolver): Resolver | undefined { + if (!resolver) return undefined; + if (resolver.fnName === 'cache') return resolver; + for (const child of resolver.arrArgs ?? []) { + const found = this._findCacheResolver(child); + if (found) return found; + } + return undefined; + } + /** Programmatic definitions not tied to a data source (e.g. builtin vars) */ _internalDefs: Array = []; diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 28469081..b679a185 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -15,6 +15,7 @@ import { } from './decorators'; import { getErrorLocation } from './error-location'; import type { VarlockPlugin } from './plugins'; +import { runWithResolutionContext, getResolutionContext } from './resolution-context'; import { getCiEnv, type CiEnvInfo } from '@varlock/ci-env-info'; import { BUILTIN_VARS, isBuiltinVar } from './builtin-vars'; @@ -64,6 +65,14 @@ export class EnvGraph { basePath?: string; + // -- Cache -- + /** @internal cache store instance, initialized during loading */ + _cacheStore?: import('../../lib/cache/cache-store').CacheStore; + /** @internal --clear-cache flag: clear cache then resolve + rewrite */ + _clearCacheMode = false; + /** @internal --skip-cache flag: skip cache entirely */ + _skipCacheMode = false; + /** root data source (.env.schema) */ rootDataSource?: EnvGraphDataSource; @@ -485,7 +494,19 @@ export class EnvGraph { // mark item as beginning to actually resolve itemsToResolveStatus[itemKey] = true; // true means in progress - await item.resolve(); + await runWithResolutionContext({ + cacheStore: this._cacheStore, + skipCache: this._skipCacheMode, + clearCache: this._clearCacheMode, + cacheHits: [], + currentItem: item, + }, async () => { + await item.resolve(); + const ctx = getResolutionContext(); + if (ctx?.cacheHits.length) { + item._cacheHits = ctx.cacheHits; + } + }); markItemCompleted(itemKey); }; diff --git a/packages/varlock/src/env-graph/lib/loader.ts b/packages/varlock/src/env-graph/lib/loader.ts index d847c9c5..1b9e211d 100644 --- a/packages/varlock/src/env-graph/lib/loader.ts +++ b/packages/varlock/src/env-graph/lib/loader.ts @@ -3,6 +3,8 @@ import path from 'node:path'; import _ from '@env-spec/utils/my-dash'; import { EnvGraph } from './env-graph'; import { DirectoryDataSource, DotEnvFileDataSource } from './data-source'; +import { CacheStore } from '../../lib/cache'; +import * as localEncrypt from '../../lib/local-encrypt'; export async function loadEnvGraph(opts?: { basePath?: string, @@ -11,10 +13,29 @@ export async function loadEnvGraph(opts?: { checkGitIgnored?: boolean, excludeDirs?: Array, currentEnvFallback?: string, + clearCache?: boolean, + skipCache?: boolean, afterInit?: (graph: EnvGraph) => Promise, }) { const graph = new EnvGraph(); + // set cache mode flags + if (opts?.clearCache) graph._clearCacheMode = true; + if (opts?.skipCache) graph._skipCacheMode = true; + + // initialize cache store (graceful — if encryption key doesn't exist, skip caching) + if (!opts?.skipCache) { + try { + await localEncrypt.ensureKey(); + graph._cacheStore = new CacheStore(); + if (graph._clearCacheMode) { + graph._cacheStore.clearAll(); + } + } catch { + // cache unavailable — proceed without caching + } + } + if (opts?.entryFilePath) { const resolvedPath = path.resolve(opts.entryFilePath); const isDirectory = opts.entryFilePath.endsWith('/') || opts.entryFilePath.endsWith(path.sep) diff --git a/packages/varlock/src/env-graph/lib/plugins.ts b/packages/varlock/src/env-graph/lib/plugins.ts index 6c79ffb7..35015056 100644 --- a/packages/varlock/src/env-graph/lib/plugins.ts +++ b/packages/varlock/src/env-graph/lib/plugins.ts @@ -14,6 +14,8 @@ import { isCancel } from '@clack/prompts'; import _ from '@env-spec/utils/my-dash'; import { pathExists } from '@env-spec/utils/fs-utils'; import { getUserVarlockDir } from '../../lib/user-config-dir'; +import { PluginCacheAccessor } from '../../lib/cache/plugin-cache-accessor'; +import type { CacheStore } from '../../lib/cache/cache-store'; import { confirm } from '../../cli/helpers/prompts'; @@ -209,6 +211,23 @@ export class VarlockPlugin { } + // -- Cache API for plugin authors -- + private _cacheAccessor?: PluginCacheAccessor; + /** @internal set by EnvGraph when plugins are loaded */ + _cacheStore?: CacheStore; + + /** + * Scoped cache accessor for this plugin. + * Keys are automatically namespaced to prevent collisions between plugins. + */ + get cache(): PluginCacheAccessor { + if (!this._cacheAccessor) { + if (!this._cacheStore) throw new Error('Cache not available — plugin accessed cache too early'); + this._cacheAccessor = new PluginCacheAccessor(this.name, this._cacheStore); + } + return this._cacheAccessor; + } + readonly dataTypes?: Array[0]> = []; registerDataType(dataTypeDef: Parameters[0]) { this.debug('registerDataType', dataTypeDef.name); @@ -427,6 +446,11 @@ async function registerPluginInGraph(graph: EnvGraph, plugin: VarlockPlugin, plu plugin.installDecoratorInstances.push(pluginDecorator); graph.plugins.push(plugin); + // propagate cache store so plugin.cache is available during module execution + if (graph._cacheStore) { + plugin._cacheStore = graph._cacheStore; + } + // this finally executes the plugin code await plugin.executePluginModule(); diff --git a/packages/varlock/src/env-graph/lib/resolution-context.ts b/packages/varlock/src/env-graph/lib/resolution-context.ts new file mode 100644 index 00000000..ff21fb57 --- /dev/null +++ b/packages/varlock/src/env-graph/lib/resolution-context.ts @@ -0,0 +1,36 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { CacheStore } from '../../lib/cache/cache-store'; +import type { ConfigItem } from './config-item'; + +export type CacheHitInfo = { + cacheKey: string; + cachedAt: number; +}; + +export type ResolutionContextData = { + cacheStore?: CacheStore; + skipCache: boolean; + clearCache: boolean; + /** Cache hits recorded during resolution of the current item */ + cacheHits: Array; + /** The ConfigItem currently being resolved */ + currentItem: ConfigItem; +}; + +const resolutionContextStorage = new AsyncLocalStorage(); + +/** + * Run a function within a resolution context. + * Used in resolveEnvValues() to provide per-item context to resolvers via ALS. + */ +export function runWithResolutionContext(ctx: ResolutionContextData, fn: () => T): T { + return resolutionContextStorage.run(ctx, fn); +} + +/** + * Get the current resolution context, if any. + * Called by resolvers (e.g., cache()) to access the cache store and current item. + */ +export function getResolutionContext(): ResolutionContextData | undefined { + return resolutionContextStorage.getStore(); +} diff --git a/packages/varlock/src/env-graph/lib/resolver.ts b/packages/varlock/src/env-graph/lib/resolver.ts index ebac163d..9d9bbac1 100644 --- a/packages/varlock/src/env-graph/lib/resolver.ts +++ b/packages/varlock/src/env-graph/lib/resolver.ts @@ -1,5 +1,6 @@ import { exec } from 'node:child_process'; import { promisify } from 'node:util'; +import { randomBytes, randomUUID, randomInt as cryptoRandomInt } from 'node:crypto'; import _ from '@env-spec/utils/my-dash'; import { @@ -9,6 +10,7 @@ import { import { ConfigItem } from './config-item'; import { SimpleQueue } from './simple-queue'; import { ResolutionError, SchemaError, VarlockError } from './errors'; +import { parseTtl, TTL_FOREVER } from '../../lib/cache/ttl-parser'; import type { EnvGraphDataSource } from './data-source'; import { DecoratorInstance } from './decorators'; import { getErrorLocation } from './error-location'; @@ -626,6 +628,278 @@ export const IsEmptyResolver: typeof Resolver = createResolver({ }); +// ── Random value generators ──────────────────────────────────────────── + +export const RandomIntResolver: typeof Resolver = createResolver({ + name: 'randomInt', + description: 'Generate a random integer between min and max (inclusive)', + icon: 'mdi:dice-multiple', + inferredType: 'number', + argsSchema: { + type: 'array', + arrayMinLength: 0, + arrayMaxLength: 2, + }, + process() { + const args = this.arrArgs ?? []; + let min = 0; + let max = 2_147_483_647; // int32 max + if (args.length === 1) { + if (!args[0].isStatic || typeof args[0].staticValue !== 'number') { + throw new SchemaError('randomInt() max argument must be a static number'); + } + max = args[0].staticValue as number; + } else if (args.length === 2) { + if (!args[0].isStatic || typeof args[0].staticValue !== 'number') { + throw new SchemaError('randomInt() min argument must be a static number'); + } + if (!args[1].isStatic || typeof args[1].staticValue !== 'number') { + throw new SchemaError('randomInt() max argument must be a static number'); + } + min = args[0].staticValue as number; + max = args[1].staticValue as number; + } + if (!Number.isInteger(min) || !Number.isInteger(max)) { + throw new SchemaError('randomInt() arguments must be integers'); + } + if (min > max) { + throw new SchemaError(`randomInt() min (${min}) must be <= max (${max})`); + } + return { min, max }; + }, + async resolve({ min, max }) { + // crypto.randomInt is exclusive on upper bound, so +1 for inclusive + return cryptoRandomInt(min, max + 1); + }, +}); + +export const RandomFloatResolver: typeof Resolver = createResolver({ + name: 'randomFloat', + description: 'Generate a random float between min and max', + icon: 'mdi:dice-multiple', + inferredType: 'number', + argsSchema: { + type: 'mixed', + arrayMinLength: 0, + arrayMaxLength: 2, + }, + process() { + const args = this.arrArgs ?? []; + let min = 0; + let max = 1; + if (args.length === 1) { + if (!args[0].isStatic || typeof args[0].staticValue !== 'number') { + throw new SchemaError('randomFloat() max argument must be a static number'); + } + max = args[0].staticValue as number; + } else if (args.length === 2) { + if (!args[0].isStatic || typeof args[0].staticValue !== 'number') { + throw new SchemaError('randomFloat() min argument must be a static number'); + } + if (!args[1].isStatic || typeof args[1].staticValue !== 'number') { + throw new SchemaError('randomFloat() max argument must be a static number'); + } + min = args[0].staticValue as number; + max = args[1].staticValue as number; + } + if (min > max) { + throw new SchemaError(`randomFloat() min (${min}) must be <= max (${max})`); + } + const precisionResolver = this.objArgs?.precision; + let precision = 2; + if (precisionResolver) { + if (!precisionResolver.isStatic || typeof precisionResolver.staticValue !== 'number') { + throw new SchemaError('randomFloat() precision must be a static integer'); + } + precision = precisionResolver.staticValue as number; + } + return { min, max, precision }; + }, + async resolve({ min, max, precision }) { + const value = min + Math.random() * (max - min); + return Number(value.toFixed(precision)); + }, +}); + +export const RandomUuidResolver: typeof Resolver = createResolver({ + name: 'randomUuid', + description: 'Generate a random UUID v4', + icon: 'mdi:identifier', + inferredType: 'string', + async resolve() { + return randomUUID(); + }, +}); + +export const RandomHexResolver: typeof Resolver = createResolver({ + name: 'randomHex', + description: 'Generate a random hex string of the given byte length', + icon: 'mdi:dice-multiple', + inferredType: 'string', + argsSchema: { + type: 'array', + arrayMinLength: 0, + arrayMaxLength: 1, + }, + process() { + const args = this.arrArgs ?? []; + let bytes = 16; // default 32 hex chars + if (args.length === 1) { + if (!args[0].isStatic || typeof args[0].staticValue !== 'number') { + throw new SchemaError('randomHex() length argument must be a static number'); + } + bytes = args[0].staticValue as number; + if (!Number.isInteger(bytes) || bytes < 1) { + throw new SchemaError('randomHex() length must be a positive integer'); + } + } + return { bytes }; + }, + async resolve({ bytes }) { + return randomBytes(bytes).toString('hex'); + }, +}); + +export const RandomStringResolver: typeof Resolver = createResolver({ + name: 'randomString', + description: 'Generate a random string of the given length', + icon: 'mdi:dice-multiple', + inferredType: 'string', + argsSchema: { + type: 'mixed', + arrayMinLength: 0, + arrayMaxLength: 1, + }, + process() { + const args = this.arrArgs ?? []; + let length = 16; + if (args.length === 1) { + if (!args[0].isStatic || typeof args[0].staticValue !== 'number') { + throw new SchemaError('randomString() length argument must be a static number'); + } + length = args[0].staticValue as number; + if (!Number.isInteger(length) || length < 1) { + throw new SchemaError('randomString() length must be a positive integer'); + } + } + const charsetResolver = this.objArgs?.charset; + let charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + if (charsetResolver) { + if (!charsetResolver.isStatic || typeof charsetResolver.staticValue !== 'string') { + throw new SchemaError('randomString() charset must be a static string'); + } + charset = charsetResolver.staticValue as string; + if (charset.length === 0) { + throw new SchemaError('randomString() charset must not be empty'); + } + } + return { length, charset }; + }, + async resolve({ length, charset }) { + const bytes = randomBytes(length); + let result = ''; + for (let i = 0; i < length; i++) { + result += charset[bytes[i] % charset.length]; + } + return result; + }, +}); + +// ── Cache resolver ───────────────────────────────────────────────────── + +export const CacheResolver: typeof Resolver = createResolver({ + name: 'cache', + description: 'Cache the result of a resolver', + icon: 'mdi:cached', + argsSchema: { + type: 'mixed', + arrayMinLength: 1, + arrayMaxLength: 1, + }, + process() { + // pass through child resolver's inferred type + const childResolver = this.arrArgs?.[0]; + if (childResolver?.inferredType) { + this.inferredType = childResolver.inferredType; + } + + // warn if the child resolver is a static value — caching a literal is pointless + if (childResolver instanceof StaticValueResolver) { + this._schemaErrors.push(new SchemaError( + 'wraps a static value which never changes — caching has no effect', + { isWarning: true }, + )); + } + + // optional explicit cache key + const keyResolver = this.objArgs?.key; + let customKey: string | undefined; + if (keyResolver) { + if (!keyResolver.isStatic || typeof keyResolver.staticValue !== 'string') { + throw new SchemaError('key must be a static string'); + } + customKey = keyResolver.staticValue as string; + } + + // optional TTL + const ttlResolver = this.objArgs?.ttl; + let ttl: string | number | undefined; + if (ttlResolver) { + if (!ttlResolver.isStatic) { + throw new SchemaError('ttl must be a static value'); + } + const ttlVal = ttlResolver.staticValue; + if (typeof ttlVal !== 'string' && typeof ttlVal !== 'number') { + throw new SchemaError('ttl must be a string like "1h" or a number (0 = forever)'); + } + parseTtl(ttlVal); + ttl = ttlVal; + } + + return { ttl, customKey }; + }, + async resolve(state) { + const { getResolutionContext } = await import('./resolution-context'); + const ctx = getResolutionContext(); + const cacheStore = ctx?.cacheStore; + const item = ctx?.currentItem; + + const childResolver = this.arrArgs![0]; + + // Use explicit key if provided, otherwise auto-generate from file/item/resolver text + let cacheKey: string; + if (state.customKey) { + cacheKey = `resolver:custom:${state.customKey}`; + } else { + const resolverText = this._parsedNode?.toString() ?? childResolver._parsedNode?.toString() ?? 'unknown'; + const filePath = (this.dataSource as any)?.fullPath ?? this.dataSource?.label ?? 'unknown'; + cacheKey = `resolver:${filePath}:${item?.key ?? 'unknown'}:${resolverText}`; + } + + if (cacheStore && !ctx?.skipCache) { + // try cache read (unless clear-cache mode) + if (!ctx?.clearCache) { + const cached = await cacheStore.get(cacheKey); + if (cached) { + ctx?.cacheHits.push({ cacheKey, cachedAt: cached.cachedAt }); + return cached.value; + } + } + } + + // cache miss — resolve wrapped resolver + const childValue = await childResolver.resolve(); + + // write to cache (even in clear-cache mode — that's the "rewrite" part) + if (cacheStore && !ctx?.skipCache && childValue !== undefined) { + const ttlMs = state.ttl != null ? parseTtl(state.ttl) : TTL_FOREVER; + await cacheStore.set(cacheKey, childValue, ttlMs); + } + + return childValue; + }, +}); + // Special function for `@defaultSensitive=inferFromPrefix(PUBLIC_)` // we may want to formalize this pattern of a resolver function used in a root decorator // but resolved within the context of a specific item @@ -659,6 +933,12 @@ export const BaseResolvers: Array = [ FallbackResolver, RefResolver, ExecResolver, + RandomIntResolver, + RandomFloatResolver, + RandomUuidResolver, + RandomHexResolver, + RandomStringResolver, + CacheResolver, RemapResolver, IfsResolver, ForEnvResolver, diff --git a/packages/varlock/src/env-graph/test/cache-resolver.test.ts b/packages/varlock/src/env-graph/test/cache-resolver.test.ts new file mode 100644 index 00000000..5940103f --- /dev/null +++ b/packages/varlock/src/env-graph/test/cache-resolver.test.ts @@ -0,0 +1,357 @@ +/** + * Tests for the cache() resolver function. + * + * Tests schema validation, resolver wiring, and actual caching behavior + * using a random resolver and mock cache store. + */ + +import { + describe, it, expect, vi, beforeEach, afterEach, +} from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { outdent } from 'outdent'; +import { DotEnvFileDataSource, EnvGraph } from '../index'; +import { Resolver } from '../lib/resolver'; +import { CacheStore } from '../../lib/cache'; + +let tempDir: string; + +// mock localEncrypt to avoid needing real encryption keys +vi.mock('../../lib/local-encrypt', () => ({ + encryptValue: vi.fn(async (value: string) => `encrypted:${value}`), + decryptValue: vi.fn(async (value: string) => value.replace('encrypted:', '')), + // eslint-disable-next-line @typescript-eslint/no-empty-function + ensureKey: vi.fn(async () => {}), + keyExists: vi.fn(() => true), +})); + +// mock user config dir to use temp directory +vi.mock('../../lib/user-config-dir', () => ({ + getUserVarlockDir: () => tempDir, +})); + +// track call counts via mutable object (closures in static def capture the reference) +const calls = { random: 0, counter: 0 }; + +// random resolver — returns a different value each time +class RandomResolver extends Resolver { + static def = { + name: 'random', + label: 'random', + icon: '', + async resolve() { + calls.random++; + return `random-${Math.random().toString(36).slice(2)}`; + }, + }; +} + +// counter resolver — increments each call +class CounterResolver extends Resolver { + static def = { + name: 'counter', + label: 'counter', + icon: '', + async resolve() { return ++calls.counter; }, + }; +} + +async function loadAndResolve(envContent: string, opts?: { + cacheStore?: CacheStore; + clearCache?: boolean; + skipCache?: boolean; +}) { + const g = new EnvGraph(); + g.registerResolver(RandomResolver); + g.registerResolver(CounterResolver); + if (opts?.cacheStore) g._cacheStore = opts.cacheStore; + if (opts?.clearCache) g._clearCacheMode = true; + if (opts?.skipCache) g._skipCacheMode = true; + const source = new DotEnvFileDataSource('.env.schema', { + overrideContents: outdent` + # @defaultRequired=false + # --- + ${envContent} + `, + }); + await g.setRootDataSource(source); + await g.finishLoad(); + await g.resolveEnvValues(); + return g; +} + +function createTestCacheStore() { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-cache-resolver-test-')); + return new CacheStore(); +} + +beforeEach(() => { + calls.random = 0; + calls.counter = 0; +}); + +afterEach(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); +}); + +describe('cache() resolver', () => { + describe('schema validation', () => { + it('accepts cache() without ttl (defaults to forever)', async () => { + const g = await loadAndResolve('A=cache(random())'); + const item = g.configSchema.A; + expect(item.errors.length).toBe(0); + expect(item.resolvedValue).toBeDefined(); + }); + + it('rejects cache() with invalid ttl format', async () => { + const g = await loadAndResolve('A=cache("static", ttl="invalid")'); + const item = g.configSchema.A; + expect(item.errors.length).toBeGreaterThan(0); + }); + + it('accepts cache() with valid ttl', async () => { + const g = await loadAndResolve('A=cache(random(), ttl="1h")'); + const item = g.configSchema.A; + expect(item.resolvedValue).toBeDefined(); + expect(item.errors.length).toBe(0); + }); + + it('warns when wrapping a static value', async () => { + const g = await loadAndResolve('A=cache("static-val", ttl="1h")'); + const item = g.configSchema.A; + // should still resolve (warning, not error) + expect(item.resolvedValue).toBe('static-val'); + // the warning is on the resolver's schema errors + const resolverWarnings = item.resolverSchemaErrors.filter((e) => e.isWarning); + expect(resolverWarnings.length).toBeGreaterThan(0); + expect(resolverWarnings.some((e) => e.message.includes('static value'))).toBe(true); + }); + + it('accepts cache() with ttl=0 (forever)', async () => { + const g = await loadAndResolve('A=cache(random(), ttl=0)'); + const item = g.configSchema.A; + expect(item.resolvedValue).toBeDefined(); + expect(item.errors.length).toBe(0); + }); + }); + + describe('resolution without cache store', () => { + it('resolves wrapped static value', async () => { + const g = await loadAndResolve('A=cache("world", ttl="30m")'); + expect(g.configSchema.A.resolvedValue).toBe('world'); + }); + + it('resolves wrapped function', async () => { + const g = await loadAndResolve('A=cache(counter(), ttl="1h")'); + // counter returns a number but default type is string, so it gets coerced + expect(g.configSchema.A.resolvedValue).toBe('1'); + }); + + it('works with fallback wrapping cache', async () => { + const g = await loadAndResolve('A=fallback(cache("first", ttl="1h"), "second")'); + expect(g.configSchema.A.resolvedValue).toBe('first'); + }); + }); + + describe('caching behavior with cache store', () => { + it('caches a value and returns it on second resolve', async () => { + const store = createTestCacheStore(); + + // first resolve — cache miss, resolver runs + const g1 = await loadAndResolve('A=cache(random(), ttl="1h")', { cacheStore: store }); + const firstValue = g1.configSchema.A.resolvedValue; + expect(firstValue).toBeDefined(); + expect(calls.random).toBe(1); + expect(g1.configSchema.A.isCacheHit).toBe(false); + + // second resolve — cache hit, resolver should NOT run again + const g2 = await loadAndResolve('A=cache(random(), ttl="1h")', { cacheStore: store }); + expect(g2.configSchema.A.resolvedValue).toBe(firstValue); + // random resolver was only called once total (from first resolve) + expect(calls.random).toBe(1); + expect(g2.configSchema.A.isCacheHit).toBe(true); + expect(g2.configSchema.A._cacheHits.length).toBe(1); + }); + + it('cache invalidates when resolver text changes', async () => { + const store = createTestCacheStore(); + + const g1 = await loadAndResolve('A=cache("value-1", ttl="1h")', { cacheStore: store }); + expect(g1.configSchema.A.resolvedValue).toBe('value-1'); + + // change the wrapped resolver — should NOT get cached value + const g2 = await loadAndResolve('A=cache("value-2", ttl="1h")', { cacheStore: store }); + expect(g2.configSchema.A.resolvedValue).toBe('value-2'); + expect(g2.configSchema.A.isCacheHit).toBe(false); + }); + + it('--clear-cache skips reading but rewrites', async () => { + const store = createTestCacheStore(); + + // populate cache + const g1 = await loadAndResolve('A=cache(random(), ttl="1h")', { cacheStore: store }); + const firstValue = g1.configSchema.A.resolvedValue; + + // clear-cache: should resolve fresh (not return cached value) + const g2 = await loadAndResolve('A=cache(random(), ttl="1h")', { cacheStore: store, clearCache: true }); + expect(g2.configSchema.A.resolvedValue).not.toBe(firstValue); + expect(calls.random).toBe(2); + + // third resolve without clear: should get the new cached value + const g3 = await loadAndResolve('A=cache(random(), ttl="1h")', { cacheStore: store }); + expect(g3.configSchema.A.resolvedValue).toBe(g2.configSchema.A.resolvedValue); + expect(calls.random).toBe(2); // not called again + }); + + it('--skip-cache bypasses cache entirely', async () => { + const store = createTestCacheStore(); + + // populate cache + await loadAndResolve('A=cache(random(), ttl="1h")', { cacheStore: store }); + + // skip-cache: should resolve fresh and NOT write to cache + const g2 = await loadAndResolve('A=cache(random(), ttl="1h")', { cacheStore: store, skipCache: true }); + expect(calls.random).toBe(2); + expect(g2.configSchema.A.isCacheHit).toBe(false); + + // third resolve without skip: should still get original cached value (skip didn't overwrite) + const g3 = await loadAndResolve('A=cache(random(), ttl="1h")', { cacheStore: store }); + expect(calls.random).toBe(2); // cache hit from first resolve + expect(g3.configSchema.A.isCacheHit).toBe(true); + }); + + it('uses custom key when specified', async () => { + const store = createTestCacheStore(); + + // cache with custom key + const g1 = await loadAndResolve('A=cache(random(), key="my-custom-key")', { cacheStore: store }); + const firstValue = g1.configSchema.A.resolvedValue; + + // same custom key — should hit cache even though item name could differ + const g2 = await loadAndResolve('A=cache(random(), key="my-custom-key")', { cacheStore: store }); + expect(g2.configSchema.A.resolvedValue).toBe(firstValue); + expect(g2.configSchema.A.isCacheHit).toBe(true); + + // different custom key — should NOT hit cache + const g3 = await loadAndResolve('A=cache(random(), key="other-key")', { cacheStore: store }); + expect(g3.configSchema.A.resolvedValue).not.toBe(firstValue); + expect(g3.configSchema.A.isCacheHit).toBe(false); + }); + + it('caches forever when no ttl specified', async () => { + const store = createTestCacheStore(); + + const g1 = await loadAndResolve('A=cache(random())', { cacheStore: store }); + const firstValue = g1.configSchema.A.resolvedValue; + + const g2 = await loadAndResolve('A=cache(random())', { cacheStore: store }); + expect(g2.configSchema.A.resolvedValue).toBe(firstValue); + expect(g2.configSchema.A.isCacheHit).toBe(true); + }); + + it('multiple items cache independently', async () => { + const store = createTestCacheStore(); + + const g1 = await loadAndResolve(outdent` + A=cache(random(), ttl="1h") + B=cache(random(), ttl="1h") + `, { cacheStore: store }); + expect(g1.configSchema.A.resolvedValue).toBeDefined(); + expect(g1.configSchema.B.resolvedValue).toBeDefined(); + expect(g1.configSchema.A.resolvedValue).not.toBe(g1.configSchema.B.resolvedValue); + + // check cache file was written + const stats = store.getStats(); + expect(stats.total).toBe(2); + + // both should be cached on second resolve + const g2 = await loadAndResolve(outdent` + A=cache(random(), ttl="1h") + B=cache(random(), ttl="1h") + `, { cacheStore: store }); + expect(g2.configSchema.A.isCacheHit).toBe(true); + expect(g2.configSchema.B.isCacheHit).toBe(true); + expect(g2.configSchema.A.resolvedValue).toBe(g1.configSchema.A.resolvedValue); + expect(g2.configSchema.B.resolvedValue).toBe(g1.configSchema.B.resolvedValue); + }); + }); + + describe('cacheTtl / isCached properties', () => { + it('extracts TTL from cache() resolver', async () => { + const g = await loadAndResolve('A=cache("val", ttl="2h")'); + expect(g.configSchema.A.cacheTtl).toBe('2h'); + expect(g.configSchema.A.isCached).toBe(true); + }); + + it('returns undefined TTL when no ttl specified (forever)', async () => { + const g = await loadAndResolve('A=cache("val")'); + expect(g.configSchema.A.cacheTtl).toBeUndefined(); + expect(g.configSchema.A.isCached).toBe(true); + }); + + it('isCached is false when no cache() is used', async () => { + const g = await loadAndResolve('A="plain"'); + expect(g.configSchema.A.isCached).toBe(false); + expect(g.configSchema.A.cacheTtl).toBeUndefined(); + }); + + it('finds cache() nested inside other resolvers', async () => { + const g = await loadAndResolve('A=fallback(cache("val", ttl="5m"), "other")'); + expect(g.configSchema.A.cacheTtl).toBe('5m'); + expect(g.configSchema.A.isCached).toBe(true); + }); + }); + + describe('cache hit tracking', () => { + it('reports no cache hits when no cache store', async () => { + const g = await loadAndResolve('A=cache("val", ttl="1h")'); + expect(g.configSchema.A.isCacheHit).toBe(false); + expect(g.configSchema.A._cacheHits).toEqual([]); + }); + + it('records cache hit info with cacheKey and timestamp', async () => { + const store = createTestCacheStore(); + + await loadAndResolve('A=cache("val", ttl="1h")', { cacheStore: store }); + const before = Date.now(); + const g2 = await loadAndResolve('A=cache("val", ttl="1h")', { cacheStore: store }); + + expect(g2.configSchema.A.isCacheHit).toBe(true); + const hit = g2.configSchema.A._cacheHits[0]; + expect(hit.cacheKey).toContain('resolver:'); + expect(hit.cacheKey).toContain(':A:'); + expect(hit.cachedAt).toBeLessThanOrEqual(before); + }); + }); + + describe('type inference', () => { + it('infers number type from randomInt() child', async () => { + const g = await loadAndResolve('A=cache(randomInt(1, 10))'); + const item = g.configSchema.A; + // the value should be coerced as a number, not a string + expect(typeof item.resolvedValue).toBe('number'); + }); + + it('infers string type from randomUuid() child', async () => { + const g = await loadAndResolve('A=cache(randomUuid())'); + const item = g.configSchema.A; + expect(typeof item.resolvedValue).toBe('string'); + }); + }); + + describe('various TTL formats in schema', () => { + const validTtls = ['30s', '5m', '1h', '1d', '1w']; + for (const ttl of validTtls) { + it(`accepts ttl="${ttl}"`, async () => { + const g = await loadAndResolve(`A=cache(random(), ttl="${ttl}")`); + expect(g.configSchema.A.errors.length).toBe(0); + expect(g.configSchema.A.resolvedValue).toBeDefined(); + }); + } + }); +}); diff --git a/packages/varlock/src/env-graph/test/random-resolvers.test.ts b/packages/varlock/src/env-graph/test/random-resolvers.test.ts new file mode 100644 index 00000000..9717f2c5 --- /dev/null +++ b/packages/varlock/src/env-graph/test/random-resolvers.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for random value generator resolver functions: + * randomInt(), randomFloat(), randomUuid(), randomHex(), randomString() + */ + +import { describe, it, expect } from 'vitest'; +import { outdent } from 'outdent'; +import { DotEnvFileDataSource, EnvGraph } from '../index'; +import { SchemaError } from '../lib/errors'; + +async function loadAndResolve(envContent: string) { + const g = new EnvGraph(); + const source = new DotEnvFileDataSource('.env.schema', { + overrideContents: outdent` + # @defaultRequired=false + # --- + ${envContent} + `, + }); + await g.setRootDataSource(source); + await g.finishLoad(); + await g.resolveEnvValues(); + return g; +} + +describe('randomInt()', () => { + it('generates an integer with no args (0 to int32 max)', async () => { + const g = await loadAndResolve('A=randomInt()'); + const val = g.configSchema.A.resolvedValue as number; + expect(Number.isInteger(val)).toBe(true); + expect(val).toBeGreaterThanOrEqual(0); + }); + + it('generates an integer with max only', async () => { + const g = await loadAndResolve('A=randomInt(10)'); + const val = g.configSchema.A.resolvedValue as number; + expect(Number.isInteger(val)).toBe(true); + expect(val).toBeGreaterThanOrEqual(0); + expect(val).toBeLessThanOrEqual(10); + }); + + it('generates an integer in range', async () => { + const g = await loadAndResolve('A=randomInt(5, 10)'); + const val = g.configSchema.A.resolvedValue as number; + expect(Number.isInteger(val)).toBe(true); + expect(val).toBeGreaterThanOrEqual(5); + expect(val).toBeLessThanOrEqual(10); + }); + + it('rejects min > max', async () => { + const g = await loadAndResolve('A=randomInt(10, 5)'); + expect(g.configSchema.A.errors.length).toBeGreaterThan(0); + expect(g.configSchema.A.errors[0]).toBeInstanceOf(SchemaError); + }); + + it('rejects non-integer args', async () => { + const g = await loadAndResolve('A=randomInt(1.5, 10)'); + expect(g.configSchema.A.errors.length).toBeGreaterThan(0); + }); +}); + +describe('randomFloat()', () => { + it('generates a float with no args (0 to 1)', async () => { + const g = await loadAndResolve('A=randomFloat()'); + const val = g.configSchema.A.resolvedValue as number; + expect(typeof val).toBe('number'); + expect(val).toBeGreaterThanOrEqual(0); + expect(val).toBeLessThanOrEqual(1); + }); + + it('generates a float in range', async () => { + const g = await loadAndResolve('A=randomFloat(10, 20)'); + const val = g.configSchema.A.resolvedValue as number; + expect(val).toBeGreaterThanOrEqual(10); + expect(val).toBeLessThanOrEqual(20); + }); + + it('respects precision option', async () => { + const g = await loadAndResolve('A=randomFloat(0, 1, precision=4)'); + const val = g.configSchema.A.resolvedValue as number; + const decimalPlaces = val.toString().split('.')[1]?.length ?? 0; + expect(decimalPlaces).toBeLessThanOrEqual(4); + }); + + it('rejects min > max', async () => { + const g = await loadAndResolve('A=randomFloat(20, 10)'); + expect(g.configSchema.A.errors.length).toBeGreaterThan(0); + }); +}); + +describe('randomUuid()', () => { + it('generates a valid UUID v4', async () => { + const g = await loadAndResolve('A=randomUuid()'); + const val = g.configSchema.A.resolvedValue as string; + expect(val).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + }); + + it('generates unique values', async () => { + const g = await loadAndResolve(outdent` + A=randomUuid() + B=randomUuid() + `); + expect(g.configSchema.A.resolvedValue).not.toBe(g.configSchema.B.resolvedValue); + }); +}); + +describe('randomHex()', () => { + it('generates a hex string with default length (32 chars = 16 bytes)', async () => { + const g = await loadAndResolve('A=randomHex()'); + const val = g.configSchema.A.resolvedValue as string; + expect(val).toMatch(/^[0-9a-f]{32}$/); + }); + + it('generates a hex string with custom byte length', async () => { + const g = await loadAndResolve('A=randomHex(8)'); + const val = g.configSchema.A.resolvedValue as string; + expect(val).toMatch(/^[0-9a-f]{16}$/); // 8 bytes = 16 hex chars + }); + + it('rejects zero length', async () => { + const g = await loadAndResolve('A=randomHex(0)'); + expect(g.configSchema.A.errors.length).toBeGreaterThan(0); + }); +}); + +describe('randomString()', () => { + it('generates a string with default length (16) and charset', async () => { + const g = await loadAndResolve('A=randomString()'); + const val = g.configSchema.A.resolvedValue as string; + expect(val.length).toBe(16); + expect(val).toMatch(/^[A-Za-z0-9]+$/); + }); + + it('generates a string with custom length', async () => { + const g = await loadAndResolve('A=randomString(32)'); + const val = g.configSchema.A.resolvedValue as string; + expect(val.length).toBe(32); + }); + + it('generates a string with custom charset', async () => { + const g = await loadAndResolve('A=randomString(10, charset="abc")'); + const val = g.configSchema.A.resolvedValue as string; + expect(val.length).toBe(10); + expect(val).toMatch(/^[abc]+$/); + }); + + it('rejects zero length', async () => { + const g = await loadAndResolve('A=randomString(0)'); + expect(g.configSchema.A.errors.length).toBeGreaterThan(0); + }); + + it('rejects empty charset', async () => { + const g = await loadAndResolve("A=randomString(10, charset='')"); + expect(g.configSchema.A.errors.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/varlock/src/lib/cache/cache-store.test.ts b/packages/varlock/src/lib/cache/cache-store.test.ts new file mode 100644 index 00000000..96139903 --- /dev/null +++ b/packages/varlock/src/lib/cache/cache-store.test.ts @@ -0,0 +1,214 @@ +import { + describe, it, expect, vi, beforeEach, afterEach, +} from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { CacheStore } from './cache-store'; + +// mock localEncrypt to avoid needing real encryption keys +vi.mock('../local-encrypt', () => ({ + encryptValue: vi.fn(async (value: string) => `encrypted:${value}`), + decryptValue: vi.fn(async (value: string) => value.replace('encrypted:', '')), +})); + +// mock getUserVarlockDir to use a temp directory +let tempDir: string; +vi.mock('../user-config-dir', () => ({ + getUserVarlockDir: () => tempDir, +})); + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-cache-test-')); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('CacheStore', () => { + describe('get/set', () => { + it('returns undefined for missing key', async () => { + const store = new CacheStore(); + const result = await store.get('missing:key'); + expect(result).toBeUndefined(); + }); + + it('stores and retrieves a value', async () => { + const store = new CacheStore(); + await store.set('plugin:test:mykey', 'hello', 60_000); + const result = await store.get('plugin:test:mykey'); + expect(result).toBeDefined(); + expect(result!.value).toBe('hello'); + }); + + it('returns cachedAt timestamp', async () => { + const store = new CacheStore(); + const before = Date.now(); + await store.set('plugin:test:ts', 'val', 60_000); + const after = Date.now(); + const result = await store.get('plugin:test:ts'); + expect(result!.cachedAt).toBeGreaterThanOrEqual(before); + expect(result!.cachedAt).toBeLessThanOrEqual(after); + }); + + it('overwrites existing value', async () => { + const store = new CacheStore(); + await store.set('plugin:test:k', 'v1', 60_000); + await store.set('plugin:test:k', 'v2', 60_000); + const result = await store.get('plugin:test:k'); + expect(result!.value).toBe('v2'); + }); + }); + + describe('expiry', () => { + it('returns undefined for expired entry', async () => { + const store = new CacheStore(); + await store.set('plugin:test:exp', 'val', 1); // 1ms TTL + // wait for expiry + await new Promise((r) => { + setTimeout(r, 10); + }); + const result = await store.get('plugin:test:exp'); + expect(result).toBeUndefined(); + }); + }); + + describe('delete', () => { + it('removes a specific entry', async () => { + const store = new CacheStore(); + await store.set('plugin:test:a', 'va', 60_000); + await store.set('plugin:test:b', 'vb', 60_000); + store.delete('plugin:test:a'); + expect(await store.get('plugin:test:a')).toBeUndefined(); + expect((await store.get('plugin:test:b'))!.value).toBe('vb'); + }); + }); + + describe('clearAll', () => { + it('clears all entries and returns count', async () => { + const store = new CacheStore(); + await store.set('plugin:a:1', 'v1', 60_000); + await store.set('plugin:b:2', 'v2', 60_000); + const count = store.clearAll(); + expect(count).toBe(2); + expect(await store.get('plugin:a:1')).toBeUndefined(); + expect(await store.get('plugin:b:2')).toBeUndefined(); + }); + + it('returns 0 when empty', () => { + const store = new CacheStore(); + expect(store.clearAll()).toBe(0); + }); + }); + + describe('clearByPrefix', () => { + it('clears only entries matching prefix', async () => { + const store = new CacheStore(); + await store.set('plugin:1password:a', 'v1', 60_000); + await store.set('plugin:1password:b', 'v2', 60_000); + await store.set('plugin:aws:c', 'v3', 60_000); + await store.set('resolver:file:item:text', 'v4', 60_000); + + const count = store.clearByPrefix('plugin:1password:'); + expect(count).toBe(2); + expect(await store.get('plugin:1password:a')).toBeUndefined(); + expect(await store.get('plugin:1password:b')).toBeUndefined(); + expect((await store.get('plugin:aws:c'))!.value).toBe('v3'); + expect((await store.get('resolver:file:item:text'))!.value).toBe('v4'); + }); + }); + + describe('getStats', () => { + it('returns correct stats', async () => { + const store = new CacheStore(); + await store.set('plugin:1password:a', 'v1', 60_000); + await store.set('plugin:1password:b', 'v2', 60_000); + await store.set('plugin:aws:c', 'v3', 60_000); + await store.set('resolver:/path:ITEM:text()', 'v4', 60_000); + + const stats = store.getStats(); + expect(stats.total).toBe(4); + expect(stats.expired).toBe(0); + expect(stats.byPrefix['plugin:1password']).toBe(2); + expect(stats.byPrefix['plugin:aws']).toBe(1); + expect(stats.byPrefix['resolver:/path']).toBe(1); + }); + }); + + describe('persistence', () => { + it('persists across new CacheStore instances', async () => { + const store1 = new CacheStore(); + await store1.set('plugin:test:persist', 'persistent-value', 60_000); + + const store2 = new CacheStore(); + const result = await store2.get('plugin:test:persist'); + expect(result!.value).toBe('persistent-value'); + }); + }); + + describe('type preservation', () => { + it('preserves number type', async () => { + const store = new CacheStore(); + await store.set('plugin:test:num', 42, 60_000); + const result = await store.get('plugin:test:num'); + expect(result!.value).toBe(42); + expect(typeof result!.value).toBe('number'); + }); + + it('preserves boolean type', async () => { + const store = new CacheStore(); + await store.set('plugin:test:bool', true, 60_000); + const result = await store.get('plugin:test:bool'); + expect(result!.value).toBe(true); + expect(typeof result!.value).toBe('boolean'); + }); + + it('preserves object type', async () => { + const store = new CacheStore(); + await store.set('plugin:test:obj', { foo: 'bar', num: 1 }, 60_000); + const result = await store.get('plugin:test:obj'); + expect(result!.value).toEqual({ foo: 'bar', num: 1 }); + }); + + it('preserves array type', async () => { + const store = new CacheStore(); + await store.set('plugin:test:arr', [1, 'two', true], 60_000); + const result = await store.get('plugin:test:arr'); + expect(result!.value).toEqual([1, 'two', true]); + }); + }); + + describe('encryption', () => { + it('stores encrypted JSON-serialized values in the file', async () => { + const store = new CacheStore(); + await store.set('plugin:test:enc', 'secret', 60_000); + + const raw = fs.readFileSync(store.getFilePath(), 'utf-8'); + const data = JSON.parse(raw); + // value should be encrypted JSON, not plaintext + expect(data['plugin:test:enc'].v).toBe('encrypted:"secret"'); + expect(data['plugin:test:enc'].v).not.toBe('secret'); + }); + }); + + describe('graceful degradation', () => { + it('handles missing cache file gracefully', async () => { + const store = new CacheStore(); + // no file exists yet + const result = await store.get('anything'); + expect(result).toBeUndefined(); + }); + + it('handles corrupted cache file gracefully', async () => { + const store = new CacheStore(); + // write garbage to the cache file + const dir = path.dirname(store.getFilePath()); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(store.getFilePath(), 'not valid json'); + + const result = await store.get('anything'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/varlock/src/lib/cache/cache-store.ts b/packages/varlock/src/lib/cache/cache-store.ts new file mode 100644 index 00000000..ac0933e2 --- /dev/null +++ b/packages/varlock/src/lib/cache/cache-store.ts @@ -0,0 +1,222 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { getUserVarlockDir } from '../user-config-dir'; +import * as localEncrypt from '../local-encrypt'; +import { createDebug } from '../debug'; + +const debug = createDebug('varlock:cache'); + +type CacheEntry = { + /** encrypted value */ + v: string; + /** createdAt (unix ms) */ + c: number; + /** expiresAt (unix ms) */ + e: number; +}; + +type CacheData = Record; + +/** + * JSON-file-based encrypted cache store. + * + * Stores one file per encryption key at `~/.config/varlock/cache/{keyId}.json`. + * Each entry's value is individually encrypted via localEncrypt. + * Cache keys are structured strings like `plugin:name:key` or `resolver:path:item:text`. + */ +export class CacheStore { + private filePath: string; + /** In-memory cache — source of truth during a session to avoid concurrent read/write races */ + private memCache?: CacheData; + + constructor(private keyId: string = 'varlock-default') { + const cacheDir = path.join(getUserVarlockDir(), 'cache'); + this.filePath = path.join(cacheDir, `${keyId}.json`); + } + + /** + * Load and return a cached value, or undefined on miss/expired/error. + * The value is JSON-parsed after decryption to preserve its original type (number, boolean, object, etc.). + */ + async get(cacheKey: string): Promise<{ value: any; cachedAt: number } | undefined> { + const data = this.loadFile(); + const entry = data[cacheKey]; + if (!entry) return undefined; + + // check expiry + if (Date.now() > entry.e) { + debug('cache expired for %s', cacheKey); + delete data[cacheKey]; + this.saveFile(data); + return undefined; + } + + try { + const plaintext = await localEncrypt.decryptValue(entry.v, this.keyId); + return { value: JSON.parse(plaintext), cachedAt: entry.c }; + } catch (err) { + debug('cache decrypt failed for %s: %O', cacheKey, err); + // corrupt or key mismatch — treat as cache miss + delete data[cacheKey]; + this.saveFile(data); + return undefined; + } + } + + /** + * Encrypt and store a value with a TTL. + * The value is JSON-stringified before encryption to preserve its type on retrieval. + */ + async set(cacheKey: string, value: any, ttlMs: number): Promise { + const data = this.loadFile(); + const now = Date.now(); + + try { + const serialized = JSON.stringify(value); + const encrypted = await localEncrypt.encryptValue(serialized, this.keyId); + data[cacheKey] = { + v: encrypted, + c: now, + // Infinity TTL → use a far-future expiry (~100 years) + e: Number.isFinite(ttlMs) ? now + ttlMs : now + 100 * 365.25 * 86_400_000, + }; + this.saveFile(data); + debug('cache set %s (ttl=%dms)', cacheKey, ttlMs); + } catch (err) { + debug('cache encrypt failed for %s: %O', cacheKey, err); + // encryption failure is non-fatal — just skip caching + } + } + + /** + * Delete a specific cache entry. + */ + delete(cacheKey: string): void { + const data = this.loadFile(); + if (cacheKey in data) { + delete data[cacheKey]; + this.saveFile(data); + } + } + + /** + * Clear all cache entries. Returns the count of cleared entries. + */ + clearAll(): number { + const data = this.loadFile(); + const count = Object.keys(data).length; + if (count > 0) { + this.memCache = {}; + this.saveFile(this.memCache); + } + return count; + } + + /** + * Clear entries matching a key prefix. Returns the count of cleared entries. + * Example: `clearByPrefix("plugin:1password:")` clears all 1password plugin cache. + */ + clearByPrefix(prefix: string): number { + const data = this.loadFile(); + let count = 0; + for (const key of Object.keys(data)) { + if (key.startsWith(prefix)) { + delete data[key]; + count++; + } + } + if (count > 0) { + this.saveFile(data); + } + return count; + } + + /** + * Get cache statistics. + */ + getStats(): { total: number; expired: number; byPrefix: Record } { + const data = this.loadFile(); + const now = Date.now(); + let expired = 0; + const byPrefix: Record = {}; + + for (const [key, entry] of Object.entries(data)) { + if (now > entry.e) { + expired++; + continue; + } + // group by first two segments: "plugin:name" or "resolver" + const firstColon = key.indexOf(':'); + const secondColon = firstColon >= 0 ? key.indexOf(':', firstColon + 1) : -1; + const prefix = secondColon >= 0 ? key.slice(0, secondColon) : key.slice(0, firstColon); + byPrefix[prefix] = (byPrefix[prefix] || 0) + 1; + } + + return { + total: Object.keys(data).length, + expired, + byPrefix, + }; + } + + /** + * Get the file path for this cache store (for display purposes). + */ + getFilePath(): string { + return this.filePath; + } + + // -- internal -- + + private loadFile(): CacheData { + if (this.memCache) return this.memCache; + try { + if (!fs.existsSync(this.filePath)) { + this.memCache = {}; + return this.memCache; + } + const raw = fs.readFileSync(this.filePath, 'utf-8'); + const data = JSON.parse(raw) as CacheData; + + // cleanup expired entries while we're here + this.memCache = this.cleanup(data); + return this.memCache; + } catch (err) { + debug('cache file load failed: %O', err); + this.memCache = {}; + return this.memCache; + } + } + + private saveFile(data: CacheData): void { + try { + const dir = path.dirname(this.filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // atomic write: write to temp file then rename + const tmpPath = `${this.filePath}.tmp.${process.pid}`; + fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf-8'); + fs.renameSync(tmpPath, this.filePath); + } catch (err) { + debug('cache file save failed: %O', err); + } + } + + private cleanup(data: CacheData): CacheData { + const now = Date.now(); + let dirty = false; + for (const key of Object.keys(data)) { + if (now > data[key].e) { + delete data[key]; + dirty = true; + } + } + // write back cleaned data if anything was removed + if (dirty) { + this.saveFile(data); + } + return data; + } +} diff --git a/packages/varlock/src/lib/cache/index.ts b/packages/varlock/src/lib/cache/index.ts new file mode 100644 index 00000000..a00600f6 --- /dev/null +++ b/packages/varlock/src/lib/cache/index.ts @@ -0,0 +1,3 @@ +export { CacheStore } from './cache-store'; +export { parseTtl } from './ttl-parser'; +export { PluginCacheAccessor } from './plugin-cache-accessor'; diff --git a/packages/varlock/src/lib/cache/plugin-cache-accessor.ts b/packages/varlock/src/lib/cache/plugin-cache-accessor.ts new file mode 100644 index 00000000..0fb1a75f --- /dev/null +++ b/packages/varlock/src/lib/cache/plugin-cache-accessor.ts @@ -0,0 +1,42 @@ +import type { CacheStore } from './cache-store'; +import { parseTtl } from './ttl-parser'; + +/** + * Scoped cache accessor for plugin authors. + * + * All keys are automatically prefixed with `plugin:{pluginName}:` so plugins + * cannot collide with each other's cache entries. + * + * Usage in a plugin: + * ```ts + * const cached = await plugin.cache.get('vault/MyVault/item/DBCreds'); + * if (!cached) { + * const value = await fetchFromAPI(); + * await plugin.cache.set('vault/MyVault/item/DBCreds', value, '1h'); + * } + * ``` + */ +export class PluginCacheAccessor { + constructor( + private pluginName: string, + private cacheStore: CacheStore, + ) {} + + private buildKey(key: string): string { + return `plugin:${this.pluginName}:${key}`; + } + + async get(key: string): Promise { + const result = await this.cacheStore.get(this.buildKey(key)); + return result?.value; + } + + async set(key: string, value: any, ttl: string | number): Promise { + const ttlMs = typeof ttl === 'string' ? parseTtl(ttl) : ttl; + await this.cacheStore.set(this.buildKey(key), value, ttlMs); + } + + delete(key: string): void { + this.cacheStore.delete(this.buildKey(key)); + } +} diff --git a/packages/varlock/src/lib/cache/ttl-parser.test.ts b/packages/varlock/src/lib/cache/ttl-parser.test.ts new file mode 100644 index 00000000..19044ab3 --- /dev/null +++ b/packages/varlock/src/lib/cache/ttl-parser.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { parseTtl } from './ttl-parser'; + +describe('parseTtl', () => { + describe('string durations', () => { + it('parses seconds', () => { + expect(parseTtl('30s')).toBe(30_000); + }); + it('parses minutes', () => { + expect(parseTtl('5m')).toBe(300_000); + }); + it('parses hours', () => { + expect(parseTtl('1h')).toBe(3_600_000); + }); + it('parses days', () => { + expect(parseTtl('1d')).toBe(86_400_000); + }); + it('parses weeks', () => { + expect(parseTtl('1w')).toBe(604_800_000); + }); + it('handles uppercase units', () => { + expect(parseTtl('2H')).toBe(7_200_000); + }); + it('handles whitespace around value', () => { + expect(parseTtl(' 1h ')).toBe(3_600_000); + }); + it('handles fractional values', () => { + expect(parseTtl('1.5h')).toBe(5_400_000); + }); + }); + + describe('bare numbers', () => { + it('treats bare numbers as milliseconds', () => { + expect(parseTtl('5000')).toBe(5000); + }); + it('handles numeric type', () => { + expect(parseTtl(3000)).toBe(3000); + }); + }); + + describe('forever (0)', () => { + it('treats 0 as forever', () => { + expect(parseTtl(0)).toBe(Infinity); + }); + it('treats "0" string as forever', () => { + expect(parseTtl('0')).toBe(Infinity); + }); + }); + + describe('error cases', () => { + it('rejects empty string', () => { + expect(() => parseTtl('')).toThrow(); + }); + it('rejects zero with unit suffix', () => { + expect(() => parseTtl('0s')).toThrow(); + }); + it('rejects negative', () => { + expect(() => parseTtl('-5m')).toThrow(); + }); + it('rejects invalid unit', () => { + expect(() => parseTtl('5x')).toThrow(); + }); + it('rejects non-numeric string', () => { + expect(() => parseTtl('abc')).toThrow(); + }); + it('rejects negative numeric', () => { + expect(() => parseTtl(-100)).toThrow(); + }); + }); +}); diff --git a/packages/varlock/src/lib/cache/ttl-parser.ts b/packages/varlock/src/lib/cache/ttl-parser.ts new file mode 100644 index 00000000..d73dc7b8 --- /dev/null +++ b/packages/varlock/src/lib/cache/ttl-parser.ts @@ -0,0 +1,58 @@ +const TTL_UNITS: Record = { + s: 1_000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, + w: 604_800_000, +}; + +/** Sentinel value for "cache forever" (until manually cleared) */ +export const TTL_FOREVER = Infinity; + +/** + * Parse a human-readable TTL string into milliseconds. + * + * Supported formats: + * - `0` → forever (until manually cleared) + * - `"30s"` → 30,000ms + * - `"5m"` → 300,000ms + * - `"1h"` → 3,600,000ms + * - `"1d"` → 86,400,000ms + * - `"1w"` → 604,800,000ms + * - bare number → treated as milliseconds (0 = forever) + */ +export function parseTtl(ttl: string | number): number { + if (typeof ttl === 'number') { + if (ttl === 0) return TTL_FOREVER; + if (ttl < 0 || !Number.isFinite(ttl)) { + throw new Error(`Invalid TTL: ${ttl} — must be a positive number or 0 for forever`); + } + return ttl; + } + + const trimmed = ttl.trim(); + if (!trimmed) throw new Error('TTL string cannot be empty'); + + // try bare number (ms) + const asNum = Number(trimmed); + if (!Number.isNaN(asNum)) { + if (asNum === 0) return TTL_FOREVER; + if (asNum < 0) throw new Error(`Invalid TTL: "${ttl}" — must be positive or 0 for forever`); + return asNum; + } + + const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*([smhdw])$/i); + if (!match) { + throw new Error( + `Invalid TTL: "${ttl}" — expected a number with a unit suffix (s, m, h, d, w), e.g. "1h" or "30m"`, + ); + } + + const value = parseFloat(match[1]); + const unit = match[2].toLowerCase(); + const multiplier = TTL_UNITS[unit]; + + if (value <= 0) throw new Error(`Invalid TTL: "${ttl}" — must be positive`); + + return Math.round(value * multiplier); +} diff --git a/packages/varlock/src/lib/formatting.ts b/packages/varlock/src/lib/formatting.ts index 2f226484..aacfde48 100644 --- a/packages/varlock/src/lib/formatting.ts +++ b/packages/varlock/src/lib/formatting.ts @@ -88,6 +88,18 @@ const VALIDATION_STATE_COLORS = { valid: 'cyan', } as const; +export function formatTimeAgo(timestamp: number): string { + const diffMs = Date.now() - timestamp; + const diffS = Math.floor(diffMs / 1000); + if (diffS < 60) return `${diffS}s ago`; + const diffM = Math.floor(diffS / 60); + if (diffM < 60) return `${diffM}m ago`; + const diffH = Math.floor(diffM / 60); + if (diffH < 24) return `${diffH}h ago`; + const diffD = Math.floor(diffH / 24); + return `${diffD}d ago`; +} + export function getItemSummary(item: ConfigItem) { const summary: Array = []; const itemErrors = item.errors; @@ -118,6 +130,12 @@ export function getItemSummary(item: ConfigItem) { ), ])); + if (item.isCacheHit) { + const oldest = Math.min(...item._cacheHits.map((h) => h.cachedAt)); + const timeAgo = formatTimeAgo(oldest); + summary.push(` 📦 ${ansis.blue.italic(`cached ${timeAgo}`)}`); + } + if (item.isOverridden) { summary.push(` 🟡 ${ansis.yellow.italic('set via process.env override')}`); } diff --git a/packages/varlock/src/lib/load-graph.ts b/packages/varlock/src/lib/load-graph.ts index 183075e0..2ce80dac 100644 --- a/packages/varlock/src/lib/load-graph.ts +++ b/packages/varlock/src/lib/load-graph.ts @@ -12,6 +12,10 @@ export function loadVarlockEnvGraph(opts?: { currentEnvFallback?: string, /** Explicit entry file path - overrides package.json config */ entryFilePath?: string, + /** Clear cache and re-resolve all values */ + clearCache?: boolean, + /** Skip cache entirely for this invocation */ + skipCache?: boolean, }) { const pkgLoadPath = readVarlockPackageJsonConfig()?.loadPath; const resolvedEntryFilePath = opts?.entryFilePath ?? pkgLoadPath; @@ -43,6 +47,8 @@ export function loadVarlockEnvGraph(opts?: { return runWithWorkspaceInfo(() => loadEnvGraph({ ...opts, entryFilePath: resolvedEntryFilePath, + clearCache: opts?.clearCache, + skipCache: opts?.skipCache, afterInit: async (_g) => { // TODO: register varlock resolver }, diff --git a/packages/varlock/src/plugin-lib.ts b/packages/varlock/src/plugin-lib.ts index 77e6e754..f4489ea3 100644 --- a/packages/varlock/src/plugin-lib.ts +++ b/packages/varlock/src/plugin-lib.ts @@ -2,6 +2,7 @@ import type { VarlockPlugin } from './env-graph/lib/plugins'; import { pluginProxy } from './plugin-context'; export type { Resolver } from './env-graph/lib/resolver'; +export type { PluginCacheAccessor } from './lib/cache/plugin-cache-accessor'; export { createDebug, type Debugger } from './lib/debug'; // Error classes exported directly so plugin authors can import them without From 421129e45918052e9ec60aa36e998cb1bcfd3c29 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 9 Apr 2026 13:17:42 -0700 Subject: [PATCH 2/8] add opt-in caching to 1password plugin via cacheTtl option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @initOp() now accepts a cacheTtl option that caches op() and opLoadEnvironment() results via the plugin cache API. The TTL is resolved at runtime so it can be dynamic — e.g., cacheTtl=if(forEnv(dev), "1h") to only cache during development. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/plugins/1password/src/plugin.ts | 54 +++++++++++++++++-- .../src/content/docs/plugins/1password.mdx | 12 +++++ .../src/lib/cache/plugin-cache-accessor.ts | 16 +++++- packages/varlock/src/plugin-lib.ts | 1 + 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/plugins/1password/src/plugin.ts b/packages/plugins/1password/src/plugin.ts index 7e57edbe..df22199e 100644 --- a/packages/plugins/1password/src/plugin.ts +++ b/packages/plugins/1password/src/plugin.ts @@ -1,4 +1,4 @@ -import { type Resolver, plugin } from 'varlock/plugin-lib'; +import { type Resolver, type PluginCacheAccessor, plugin } from 'varlock/plugin-lib'; import { createDeferredPromise, type DeferredPromise } from '@env-spec/utils/defer'; import { Client, createClient } from '@1password/sdk'; @@ -12,6 +12,15 @@ const OP_ICON = 'simple-icons:1password'; plugin.name = '1pass'; const { debug } = plugin; debug('init - version =', plugin.version); + +// capture cache accessor while the plugin proxy context is active +// (the `plugin` proxy is only valid during module initialization, not during resolve()) +let pluginCache: PluginCacheAccessor | undefined; +try { + pluginCache = plugin.cache; +} catch { + // cache not available (e.g., no encryption key) +} plugin.icon = OP_ICON; plugin.standardVars = { initDecorator: '@initOp', @@ -95,6 +104,8 @@ class OpPluginInstance { private connectHost?: string; /** API token for authenticating with the Connect server */ private connectToken?: string; + /** optional cache TTL - when set, resolved values are cached */ + cacheTtl?: string | number; constructor( readonly id: string, @@ -429,13 +440,14 @@ plugin.registerRootDecorator({ id, account, connectHost, + cacheTtlResolver: objArgs.cacheTtl, tokenResolver: objArgs.token, allowAppAuthResolver: objArgs.allowAppAuth, connectTokenResolver: objArgs.connectToken, }; }, async execute({ - id, account, connectHost, tokenResolver, allowAppAuthResolver, connectTokenResolver, + id, account, connectHost, cacheTtlResolver, tokenResolver, allowAppAuthResolver, connectTokenResolver, }) { // even if these are empty, we can't throw errors yet // in case the instance is never actually used @@ -449,6 +461,13 @@ plugin.registerRootDecorator({ connectHost, connectToken as string | undefined, ); + // cacheTtl is resolved at runtime so it can be dynamic (e.g., cacheTtl=if(forEnv(dev), "1h")) + const cacheTtl = await cacheTtlResolver?.resolve(); + if (cacheTtl !== undefined && cacheTtl !== false && cacheTtl !== '' + && (typeof cacheTtl === 'string' || typeof cacheTtl === 'number') + ) { + pluginInstances[id].cacheTtl = cacheTtl; + } }, }); @@ -541,8 +560,21 @@ plugin.registerResolverFunction({ if (typeof opReference !== 'string') { throw new SchemaError('expected op item location to resolve to a string'); } - const opValue = await selectedInstance.readItem(opReference); - return opValue; + + // check cache if cacheTtl is configured and cache is available + if (selectedInstance.cacheTtl !== undefined && pluginCache) { + const cacheKey = `op:${instanceId}:${opReference}`; + const cached = await pluginCache.get(cacheKey); + if (cached !== undefined) { + debug('cache hit for %s', cacheKey); + return cached; + } + const opValue = await selectedInstance.readItem(opReference); + await pluginCache.set(cacheKey, opValue, selectedInstance.cacheTtl); + return opValue; + } + + return await selectedInstance.readItem(opReference); }, }); @@ -602,6 +634,20 @@ plugin.registerResolverFunction({ if (typeof environmentId !== 'string') { throw new SchemaError('expected environment ID to resolve to a string'); } + + // check cache if cacheTtl is configured and cache is available + if (selectedInstance.cacheTtl !== undefined && pluginCache) { + const cacheKey = `opEnv:${instanceId}:${environmentId}`; + const cached = await pluginCache.get(cacheKey); + if (cached !== undefined) { + debug('cache hit for %s', cacheKey); + return cached; + } + const result = await selectedInstance.readEnvironment(environmentId); + await pluginCache.set(cacheKey, result, selectedInstance.cacheTtl); + return result; + } + return await selectedInstance.readEnvironment(environmentId); }, }); diff --git a/packages/varlock-website/src/content/docs/plugins/1password.mdx b/packages/varlock-website/src/content/docs/plugins/1password.mdx index 59dfaf34..8de2bbdd 100644 --- a/packages/varlock-website/src/content/docs/plugins/1password.mdx +++ b/packages/varlock-website/src/content/docs/plugins/1password.mdx @@ -187,6 +187,7 @@ Initializes an instance of the 1Password plugin - setting up options and authent - `token` (optional): service account token. Should be a reference to a config item of type `opServiceAccountToken`. - `allowAppAuth` (optional): boolean flag to enable authenticating using the local desktop app - `account` (optional): limits the `op` cli to connect to specific 1Password account (shorthand, sign-in address, account ID, or user ID) +- `cacheTtl` (optional): when set, resolved values from `op()` and `opLoadEnvironment()` are cached locally for the specified duration. Accepts the same format as the [`cache()` function](/reference/functions/#cache) — e.g., `"5m"`, `"1h"`, `"1d"`, or `0` for forever. ```env-spec "@initOp" # @initOp(id=notProd, token=$OP_TOKEN, allowAppAuth=forEnv(dev), account=acmeco) @@ -194,6 +195,17 @@ Initializes an instance of the 1Password plugin - setting up options and authent # @type=opServiceAccountToken OP_TOKEN= ``` + +```env-spec "@initOp" title="With caching enabled" +# Cache all 1Password lookups for 1 hour +# @initOp(token=$OP_TOKEN, allowAppAuth=true, cacheTtl="1h") +``` + +Since `cacheTtl` is resolved at runtime, you can use dynamic values to conditionally enable caching: + +```env-spec "@initOp" title="Cache only in development" +# @initOp(token=$OP_TOKEN, allowAppAuth=true, cacheTtl=if(forEnv(dev), "1h")) +```
diff --git a/packages/varlock/src/lib/cache/plugin-cache-accessor.ts b/packages/varlock/src/lib/cache/plugin-cache-accessor.ts index 0fb1a75f..3753b857 100644 --- a/packages/varlock/src/lib/cache/plugin-cache-accessor.ts +++ b/packages/varlock/src/lib/cache/plugin-cache-accessor.ts @@ -7,6 +7,9 @@ import { parseTtl } from './ttl-parser'; * All keys are automatically prefixed with `plugin:{pluginName}:` so plugins * cannot collide with each other's cache entries. * + * Cache hits are automatically recorded on the current resolution context + * (if any) so they show up in `varlock load` and `varlock explain` output. + * * Usage in a plugin: * ```ts * const cached = await plugin.cache.get('vault/MyVault/item/DBCreds'); @@ -27,7 +30,18 @@ export class PluginCacheAccessor { } async get(key: string): Promise { - const result = await this.cacheStore.get(this.buildKey(key)); + const cacheKey = this.buildKey(key); + const result = await this.cacheStore.get(cacheKey); + if (result) { + // automatically record cache hit on the resolution context (if active) + try { + const { getResolutionContext } = await import('../../env-graph/lib/resolution-context'); + const ctx = getResolutionContext(); + ctx?.cacheHits.push({ cacheKey, cachedAt: result.cachedAt }); + } catch { + // resolution context not available — that's fine + } + } return result?.value; } diff --git a/packages/varlock/src/plugin-lib.ts b/packages/varlock/src/plugin-lib.ts index f4489ea3..8c5720fd 100644 --- a/packages/varlock/src/plugin-lib.ts +++ b/packages/varlock/src/plugin-lib.ts @@ -3,6 +3,7 @@ import { pluginProxy } from './plugin-context'; export type { Resolver } from './env-graph/lib/resolver'; export type { PluginCacheAccessor } from './lib/cache/plugin-cache-accessor'; +export { parseTtl } from './lib/cache/ttl-parser'; export { createDebug, type Debugger } from './lib/debug'; // Error classes exported directly so plugin authors can import them without From 19826296f288a02ab6341a2d84ee1d683d01f782 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 9 Apr 2026 13:28:17 -0700 Subject: [PATCH 3/8] move cache and process.env indicators inline with value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display cache hit (📦) and process.env override (🟡) indicators on the same line as the resolved value instead of separate lines, making the varlock load pretty output more compact and scannable. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/varlock/src/lib/formatting.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/varlock/src/lib/formatting.ts b/packages/varlock/src/lib/formatting.ts index aacfde48..a17edfc8 100644 --- a/packages/varlock/src/lib/formatting.ts +++ b/packages/varlock/src/lib/formatting.ts @@ -121,6 +121,16 @@ export function getItemSummary(item: ConfigItem) { valAsStr = redactString(item.resolvedValue)!; } + // build inline indicators to append after the value + const indicators: Array = []; + if (item.isCacheHit) { + const oldest = Math.min(...item._cacheHits.map((h) => h.cachedAt)); + indicators.push(ansis.gray(`📦 ${formatTimeAgo(oldest)}`)); + } + if (item.isOverridden) { + indicators.push(ansis.yellow('🟡 process.env')); + } + summary.push(joinAndCompact([ ansis.gray(' └'), valAsStr, @@ -128,18 +138,9 @@ export function getItemSummary(item: ConfigItem) { ansis.gray.italic('< coerced from ') + (isSensitive ? formattedValue(item.resolvedRawValue) : formattedValue(item.resolvedRawValue, false)) ), + indicators.length > 0 && ansis.gray(' ') + indicators.join(' '), ])); - if (item.isCacheHit) { - const oldest = Math.min(...item._cacheHits.map((h) => h.cachedAt)); - const timeAgo = formatTimeAgo(oldest); - summary.push(` 📦 ${ansis.blue.italic(`cached ${timeAgo}`)}`); - } - - if (item.isOverridden) { - summary.push(` 🟡 ${ansis.yellow.italic('set via process.env override')}`); - } - itemErrors?.forEach((err) => { summary.push(ansis[err.isWarning ? 'yellow' : 'red'](` - ${err.isWarning ? '[WARNING] ' : ''}${err.message}`)); From 464d4df5b695c7395a86769fda7685c51eafac25 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 9 Apr 2026 13:32:14 -0700 Subject: [PATCH 4/8] derive cache TTL display from actual cache entry expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add expiresAt to cache hit info so varlock explain can show the actual TTL for any cached value — including plugin-cached values that don't use the cache() resolver. TTLs over ~50 years display as "forever". Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/cli/commands/explain.command.ts | 18 ++++++++++++------ .../src/env-graph/lib/resolution-context.ts | 1 + packages/varlock/src/env-graph/lib/resolver.ts | 2 +- packages/varlock/src/lib/cache/cache-store.ts | 4 ++-- .../src/lib/cache/plugin-cache-accessor.ts | 2 +- packages/varlock/src/lib/formatting.ts | 14 ++++++++++++++ 6 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/varlock/src/cli/commands/explain.command.ts b/packages/varlock/src/cli/commands/explain.command.ts index 61f1017f..f375d67a 100644 --- a/packages/varlock/src/cli/commands/explain.command.ts +++ b/packages/varlock/src/cli/commands/explain.command.ts @@ -3,7 +3,7 @@ import { define } from 'gunshi'; import { gracefulExit } from 'exit-hook'; import { loadVarlockEnvGraph } from '../../lib/load-graph'; -import { formattedValue, formatTimeAgo } from '../../lib/formatting'; +import { formattedValue, formatTimeAgo, formatDuration } from '../../lib/formatting'; import { redactString } from '../../runtime/lib/redaction'; import { checkForSchemaErrors, checkForNoEnvFiles, @@ -150,15 +150,21 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = // Cache info if (item.isCached || item.isCacheHit) { - const cacheTtl = item.cacheTtl; - const ttlDisplay = cacheTtl !== undefined ? String(cacheTtl) : 'forever'; console.log(''); console.log(ansis.bold(' Cache')); - console.log(` ${ansis.gray('TTL:')} ${ttlDisplay}`); + if (item.isCacheHit) { - const oldest = Math.min(...item._cacheHits.map((h) => h.cachedAt)); - console.log(` ${ansis.blue('Status:')} hit (cached ${formatTimeAgo(oldest)})`); + const hit = item._cacheHits[0]; + const ttlMs = hit.expiresAt - hit.cachedAt; + // ~100 years is our sentinel for "forever" + const ttlDisplay = ttlMs > 50 * 365.25 * 86_400_000 ? 'forever' : formatDuration(ttlMs); + console.log(` ${ansis.gray('TTL:')} ${ttlDisplay}`); + console.log(` ${ansis.blue('Status:')} hit (cached ${formatTimeAgo(hit.cachedAt)})`); } else { + // cache miss — show TTL from the cache() resolver if available + const cacheTtl = item.cacheTtl; + const ttlDisplay = cacheTtl !== undefined ? String(cacheTtl) : 'forever'; + console.log(` ${ansis.gray('TTL:')} ${ttlDisplay}`); console.log(` ${ansis.gray('Status:')} miss (freshly resolved)`); } } diff --git a/packages/varlock/src/env-graph/lib/resolution-context.ts b/packages/varlock/src/env-graph/lib/resolution-context.ts index ff21fb57..8747e5bc 100644 --- a/packages/varlock/src/env-graph/lib/resolution-context.ts +++ b/packages/varlock/src/env-graph/lib/resolution-context.ts @@ -5,6 +5,7 @@ import type { ConfigItem } from './config-item'; export type CacheHitInfo = { cacheKey: string; cachedAt: number; + expiresAt: number; }; export type ResolutionContextData = { diff --git a/packages/varlock/src/env-graph/lib/resolver.ts b/packages/varlock/src/env-graph/lib/resolver.ts index 9d9bbac1..9f197a18 100644 --- a/packages/varlock/src/env-graph/lib/resolver.ts +++ b/packages/varlock/src/env-graph/lib/resolver.ts @@ -881,7 +881,7 @@ export const CacheResolver: typeof Resolver = createResolver({ if (!ctx?.clearCache) { const cached = await cacheStore.get(cacheKey); if (cached) { - ctx?.cacheHits.push({ cacheKey, cachedAt: cached.cachedAt }); + ctx?.cacheHits.push({ cacheKey, cachedAt: cached.cachedAt, expiresAt: cached.expiresAt }); return cached.value; } } diff --git a/packages/varlock/src/lib/cache/cache-store.ts b/packages/varlock/src/lib/cache/cache-store.ts index ac0933e2..bd1fa507 100644 --- a/packages/varlock/src/lib/cache/cache-store.ts +++ b/packages/varlock/src/lib/cache/cache-store.ts @@ -38,7 +38,7 @@ export class CacheStore { * Load and return a cached value, or undefined on miss/expired/error. * The value is JSON-parsed after decryption to preserve its original type (number, boolean, object, etc.). */ - async get(cacheKey: string): Promise<{ value: any; cachedAt: number } | undefined> { + async get(cacheKey: string): Promise<{ value: any; cachedAt: number; expiresAt: number } | undefined> { const data = this.loadFile(); const entry = data[cacheKey]; if (!entry) return undefined; @@ -53,7 +53,7 @@ export class CacheStore { try { const plaintext = await localEncrypt.decryptValue(entry.v, this.keyId); - return { value: JSON.parse(plaintext), cachedAt: entry.c }; + return { value: JSON.parse(plaintext), cachedAt: entry.c, expiresAt: entry.e }; } catch (err) { debug('cache decrypt failed for %s: %O', cacheKey, err); // corrupt or key mismatch — treat as cache miss diff --git a/packages/varlock/src/lib/cache/plugin-cache-accessor.ts b/packages/varlock/src/lib/cache/plugin-cache-accessor.ts index 3753b857..d91849d0 100644 --- a/packages/varlock/src/lib/cache/plugin-cache-accessor.ts +++ b/packages/varlock/src/lib/cache/plugin-cache-accessor.ts @@ -37,7 +37,7 @@ export class PluginCacheAccessor { try { const { getResolutionContext } = await import('../../env-graph/lib/resolution-context'); const ctx = getResolutionContext(); - ctx?.cacheHits.push({ cacheKey, cachedAt: result.cachedAt }); + ctx?.cacheHits.push({ cacheKey, cachedAt: result.cachedAt, expiresAt: result.expiresAt }); } catch { // resolution context not available — that's fine } diff --git a/packages/varlock/src/lib/formatting.ts b/packages/varlock/src/lib/formatting.ts index a17edfc8..a2075300 100644 --- a/packages/varlock/src/lib/formatting.ts +++ b/packages/varlock/src/lib/formatting.ts @@ -88,6 +88,20 @@ const VALIDATION_STATE_COLORS = { valid: 'cyan', } as const; +export function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h`; + const d = Math.floor(h / 24); + if (d < 7) return `${d}d`; + const w = Math.floor(d / 7); + return `${w}w`; +} + export function formatTimeAgo(timestamp: number): string { const diffMs = Date.now() - timestamp; const diffS = Math.floor(diffMs / 1000); From 4ae32f669f12a62d13e2c3ffa47d2b03a387c0e4 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 9 Apr 2026 13:36:16 -0700 Subject: [PATCH 5/8] make varlock cache command interactive Running `varlock cache` now shows an interactive list of all cached entries with TTL and age info. Users can scroll through entries and delete individual ones, or clear all at once. `varlock cache clear` still works as a non-interactive fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../varlock/src/cli/commands/cache.command.ts | 188 +++++++++++++++--- packages/varlock/src/lib/cache/cache-store.ts | 12 ++ 2 files changed, 170 insertions(+), 30 deletions(-) diff --git a/packages/varlock/src/cli/commands/cache.command.ts b/packages/varlock/src/cli/commands/cache.command.ts index 14ab1242..27c70c77 100644 --- a/packages/varlock/src/cli/commands/cache.command.ts +++ b/packages/varlock/src/cli/commands/cache.command.ts @@ -1,9 +1,12 @@ import fs from 'node:fs'; import ansis from 'ansis'; import { define } from 'gunshi'; +import { isCancel } from '@clack/prompts'; import { CacheStore } from '../../lib/cache'; +import { formatTimeAgo, formatDuration } from '../../lib/formatting'; import * as localEncrypt from '../../lib/local-encrypt'; +import { select, confirm } from '../helpers/prompts'; import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; export const commandSpec = define({ @@ -19,15 +22,57 @@ export const commandSpec = define({ Manage the encrypted value cache used by cache() and plugin authors. Examples: - varlock cache # Show cache status + varlock cache # Interactive cache browser varlock cache clear # Clear all cache entries varlock cache clear --plugin 1password # Clear cache for specific plugin `.trim(), }); +type CacheEntry = { key: string; cachedAt: number; expiresAt: number }; + +function formatEntryLabel(entry: CacheEntry): string { + const ttlMs = entry.expiresAt - entry.cachedAt; + const isForever = ttlMs > 50 * 365.25 * 86_400_000; + const ttlStr = isForever ? 'forever' : formatDuration(ttlMs); + const agoStr = formatTimeAgo(entry.cachedAt); + + const parts = entry.key.split(':'); + let line1: string; + const line2 = ansis.gray(` ttl: ${ttlStr} · cached ${agoStr}`); + + if (parts[0] === 'plugin') { + const pluginName = parts[1]; + const rest = parts.slice(2).join(':'); + line1 = `${ansis.magenta(`[${pluginName}]`)} ${rest}`; + } else if (parts[0] === 'resolver' && parts[1] === 'custom') { + line1 = `${ansis.cyan('[custom]')} ${parts.slice(2).join(':')}`; + } else if (parts[0] === 'resolver') { + const itemKey = parts[2]; + const resolverText = parts.slice(3).join(':'); + line1 = `${ansis.cyan(itemKey)} ${ansis.gray('=')} ${resolverText}`; + } else { + line1 = entry.key; + } + + return `${line1}\n ${line2}`; +} + +/** Group entries by their prefix (e.g., "plugin:1pass", "resolver") */ +function groupEntries(entries: Array): Record> { + const groups: Record> = {}; + for (const entry of entries) { + const firstColon = entry.key.indexOf(':'); + const secondColon = firstColon >= 0 ? entry.key.indexOf(':', firstColon + 1) : -1; + const prefix = secondColon >= 0 ? entry.key.slice(0, secondColon) : entry.key.slice(0, firstColon); + groups[prefix] ??= []; + groups[prefix].push(entry); + } + return groups; +} + export const commandFn: TypedGunshiCommandFn = async (ctx) => { const positionals = (ctx.positionals ?? []).slice(ctx.commandPath?.length ?? 0); - const action = positionals[0] ?? 'status'; + const action = positionals[0]; if (!localEncrypt.keyExists()) { console.log(ansis.gray(' No encryption key found — cache is not active.')); @@ -36,31 +81,8 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = const store = new CacheStore(); - if (action === 'status') { - const stats = store.getStats(); - const filePath = store.getFilePath(); - - console.log(''); - console.log(ansis.bold(' Cache status')); - console.log(` ${ansis.gray('File:')} ${filePath}`); - - if (fs.existsSync(filePath)) { - const fileSize = fs.statSync(filePath).size; - const sizeStr = fileSize < 1024 ? `${fileSize}B` : `${(fileSize / 1024).toFixed(1)}KB`; - console.log(` ${ansis.gray('Size:')} ${sizeStr}`); - } - - console.log(` ${ansis.gray('Entries:')} ${stats.total} (${stats.expired} expired)`); - - if (Object.keys(stats.byPrefix).length > 0) { - console.log(''); - console.log(ansis.bold(' Entries by type')); - for (const [prefix, count] of Object.entries(stats.byPrefix)) { - console.log(` ${ansis.cyan(prefix)}: ${count}`); - } - } - console.log(''); - } else if (action === 'clear') { + // non-interactive clear + if (action === 'clear') { const pluginName = ctx.values.plugin; let count: number; @@ -71,8 +93,114 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = count = store.clearAll(); console.log(` Cleared ${count} cache entries`); } - } else { - console.log(ansis.red(` Unknown action: ${action}`)); - console.log(' Available actions: status, clear'); + return; + } + + // interactive mode (default) + while (true) { + const entries = store.listEntries(); + + if (entries.length === 0) { + console.log(ansis.gray('\n Cache is empty.\n')); + return; + } + + const groups = groupEntries(entries); + const filePath = store.getFilePath(); + const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0; + const sizeStr = fileSize < 1024 ? `${fileSize}B` : `${(fileSize / 1024).toFixed(1)}KB`; + console.log(`\n ${ansis.bold(`${entries.length} cached entries`)} ${ansis.gray(`(${sizeStr})`)}`); + + // build top-level menu: one option per group + global actions + const options: Array<{ value: string; label: string }> = []; + + for (const [prefix, items] of Object.entries(groups)) { + const label = prefix.startsWith('plugin:') + ? `${ansis.magenta(`[${prefix.replace('plugin:', '')}]`)} plugin cache` + : `${ansis.cyan('[resolver]')} cached values`; + options.push({ + value: `group:${prefix}`, + label: `${label} ${ansis.gray(`(${items.length} entries)`)}`, + }); + } + + options.push({ value: '__clear_all__', label: ansis.red(`Clear all ${entries.length} entries`) }); + options.push({ value: '__exit__', label: ansis.gray('Exit') }); + + const selected = await select({ + message: 'Select a group to browse or an action:', + options, + }); + + if (isCancel(selected) || selected === '__exit__') return; + + if (selected === '__clear_all__') { + const confirmed = await confirm({ + message: `Clear all ${entries.length} cache entries?`, + initialValue: false, + }); + if (isCancel(confirmed) || !confirmed) continue; + const count = store.clearAll(); + console.log(` Cleared ${count} entries`); + return; + } + + if (typeof selected === 'string' && selected.startsWith('group:')) { + const prefix = selected.replace('group:', ''); + const groupLabel = prefix.startsWith('plugin:') + ? `${prefix.replace('plugin:', '')} plugin` + : 'resolver cache'; + + // show all entries in the group with clear-all and delete options + while (true) { + const current = store.listEntries().filter((e) => { + const k = e.key; + const fc = k.indexOf(':'); + const sc = fc >= 0 ? k.indexOf(':', fc + 1) : -1; + const p = sc >= 0 ? k.slice(0, sc) : k.slice(0, fc); + return p === prefix; + }); + if (current.length === 0) { + console.log(ansis.gray(' No entries remaining in this group.')); + break; + } + + const entryOptions = [ + ...current.map((entry) => ({ + value: entry.key, + label: formatEntryLabel(entry), + })), + { value: '__clear_group__', label: ansis.red(`Clear all ${current.length} entries`) }, + { value: '__back__', label: ansis.gray('← Back') }, + ]; + + const entrySelected = await select({ + message: `${groupLabel} — ${current.length} entries:`, + options: entryOptions, + }); + + if (isCancel(entrySelected) || entrySelected === '__back__') break; + + if (entrySelected === '__clear_group__') { + const confirmed = await confirm({ + message: `Clear all ${current.length} entries in "${prefix}"?`, + initialValue: false, + }); + if (isCancel(confirmed) || !confirmed) continue; + store.clearByPrefix(`${prefix}:`); + console.log(ansis.gray(` Cleared ${current.length} entries`)); + break; + } + + // delete individual entry + const confirmed = await confirm({ + message: `Delete "${entrySelected}"?`, + initialValue: true, + }); + if (isCancel(confirmed) || !confirmed) continue; + store.delete(entrySelected); + console.log(ansis.gray(' Deleted')); + } + } } }; diff --git a/packages/varlock/src/lib/cache/cache-store.ts b/packages/varlock/src/lib/cache/cache-store.ts index bd1fa507..6b028951 100644 --- a/packages/varlock/src/lib/cache/cache-store.ts +++ b/packages/varlock/src/lib/cache/cache-store.ts @@ -159,6 +159,18 @@ export class CacheStore { }; } + /** + * List all non-expired entries with their metadata (for interactive browsing). + * Values are NOT decrypted — only keys and timestamps are returned. + */ + listEntries(): Array<{ key: string; cachedAt: number; expiresAt: number }> { + const data = this.loadFile(); + const now = Date.now(); + return Object.entries(data) + .filter(([, entry]) => now <= entry.e) + .map(([key, entry]) => ({ key, cachedAt: entry.c, expiresAt: entry.e })); + } + /** * Get the file path for this cache store (for display purposes). */ From 9c2a1caee09d8f869ba701f3630a391dcef638aa Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 9 Apr 2026 14:04:56 -0700 Subject: [PATCH 6/8] support flexible TTL unit formats (hr, mins, days, etc.) TTL strings now accept shorthand and full-word variants: s/sec/secs/second/seconds, m/min/mins/minute/minutes, h/hr/hrs/hour/hours, d/day/days, w/wk/wks/week/weeks. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../varlock/src/lib/cache/ttl-parser.test.ts | 19 +++++++++++++ packages/varlock/src/lib/cache/ttl-parser.ts | 28 +++++++++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/varlock/src/lib/cache/ttl-parser.test.ts b/packages/varlock/src/lib/cache/ttl-parser.test.ts index 19044ab3..7203c339 100644 --- a/packages/varlock/src/lib/cache/ttl-parser.test.ts +++ b/packages/varlock/src/lib/cache/ttl-parser.test.ts @@ -27,6 +27,25 @@ describe('parseTtl', () => { it('handles fractional values', () => { expect(parseTtl('1.5h')).toBe(5_400_000); }); + it('parses "hr" shorthand', () => { + expect(parseTtl('1hr')).toBe(3_600_000); + }); + it('parses "hrs" shorthand', () => { + expect(parseTtl('2hrs')).toBe(7_200_000); + }); + it('parses "min" shorthand', () => { + expect(parseTtl('5min')).toBe(300_000); + }); + it('parses "mins" shorthand', () => { + expect(parseTtl('10mins')).toBe(600_000); + }); + it('parses full words', () => { + expect(parseTtl('1hour')).toBe(3_600_000); + expect(parseTtl('2days')).toBe(172_800_000); + expect(parseTtl('1week')).toBe(604_800_000); + expect(parseTtl('30seconds')).toBe(30_000); + expect(parseTtl('5minutes')).toBe(300_000); + }); }); describe('bare numbers', () => { diff --git a/packages/varlock/src/lib/cache/ttl-parser.ts b/packages/varlock/src/lib/cache/ttl-parser.ts index d73dc7b8..56898a1e 100644 --- a/packages/varlock/src/lib/cache/ttl-parser.ts +++ b/packages/varlock/src/lib/cache/ttl-parser.ts @@ -1,9 +1,27 @@ const TTL_UNITS: Record = { s: 1_000, + sec: 1_000, + secs: 1_000, + second: 1_000, + seconds: 1_000, m: 60_000, + min: 60_000, + mins: 60_000, + minute: 60_000, + minutes: 60_000, h: 3_600_000, + hr: 3_600_000, + hrs: 3_600_000, + hour: 3_600_000, + hours: 3_600_000, d: 86_400_000, + day: 86_400_000, + days: 86_400_000, w: 604_800_000, + wk: 604_800_000, + wks: 604_800_000, + week: 604_800_000, + weeks: 604_800_000, }; /** Sentinel value for "cache forever" (until manually cleared) */ @@ -41,10 +59,10 @@ export function parseTtl(ttl: string | number): number { return asNum; } - const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*([smhdw])$/i); + const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*([a-z]+)$/i); if (!match) { throw new Error( - `Invalid TTL: "${ttl}" — expected a number with a unit suffix (s, m, h, d, w), e.g. "1h" or "30m"`, + `Invalid TTL: "${ttl}" — expected a number with a unit suffix (e.g. "1h", "30m", "1hr", "2days")`, ); } @@ -52,6 +70,12 @@ export function parseTtl(ttl: string | number): number { const unit = match[2].toLowerCase(); const multiplier = TTL_UNITS[unit]; + if (!multiplier) { + throw new Error( + `Invalid TTL unit: "${match[2]}" — valid units: s, sec, m, min, h, hr, d, day, w, wk (and plurals)`, + ); + } + if (value <= 0) throw new Error(`Invalid TTL: "${ttl}" — must be positive`); return Math.round(value * multiplier); From bb9c087a27b5ccdaff25380baf8683c48b2346c4 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 9 Apr 2026 14:16:03 -0700 Subject: [PATCH 7/8] add opt-in caching to aws-secrets, bitwarden, and google-secret-manager plugins Same pattern as 1password: add cacheTtl option to each plugin's init decorator. The TTL is resolved at runtime so it can be dynamic (e.g., cacheTtl=if(forEnv(dev), "1h")). Cache keys are scoped per plugin and instance. - aws-secrets: caches awsSecret() and awsParam() calls - bitwarden: caches bitwarden() calls - google-secret-manager: caches gsm() calls Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/plugins/aws-secrets/src/plugin.ts | 48 ++++++++++++++++++- packages/plugins/bitwarden/src/plugin.ts | 40 ++++++++++++++-- .../google-secret-manager/src/plugin.ts | 38 +++++++++++++-- 3 files changed, 118 insertions(+), 8 deletions(-) diff --git a/packages/plugins/aws-secrets/src/plugin.ts b/packages/plugins/aws-secrets/src/plugin.ts index 92ed43e0..efca4384 100644 --- a/packages/plugins/aws-secrets/src/plugin.ts +++ b/packages/plugins/aws-secrets/src/plugin.ts @@ -1,4 +1,4 @@ -import { type Resolver, plugin } from 'varlock/plugin-lib'; +import { type Resolver, type PluginCacheAccessor, plugin } from 'varlock/plugin-lib'; import { SecretsManagerClient, @@ -21,6 +21,15 @@ const { debug } = plugin; debug('init - version =', plugin.version); plugin.icon = AWS_ICON; +// capture cache accessor while the plugin proxy context is active +// (the `plugin` proxy is only valid during module initialization, not during resolve()) +let pluginCache: PluginCacheAccessor | undefined; +try { + pluginCache = plugin.cache; +} catch { + // cache not available (e.g., no encryption key) +} + plugin.standardVars = { initDecorator: '@initAws', params: { @@ -45,6 +54,8 @@ class AwsPluginInstance { private sessionToken?: string; private profile?: string; private namePrefix?: string; + /** optional cache TTL - when set, resolved values are cached */ + cacheTtl?: string | number; constructor( readonly id: string, @@ -423,6 +434,7 @@ plugin.registerRootDecorator({ secretAccessKeyResolver: objArgs.secretAccessKey, sessionTokenResolver: objArgs.sessionToken, namePrefixResolver: objArgs.namePrefix, + cacheTtlResolver: objArgs.cacheTtl, }; }, async execute({ @@ -433,6 +445,7 @@ plugin.registerRootDecorator({ secretAccessKeyResolver, sessionTokenResolver, namePrefixResolver, + cacheTtlResolver, }) { const region = await regionResolver.resolve(); const accessKeyId = await accessKeyIdResolver?.resolve(); @@ -441,6 +454,13 @@ plugin.registerRootDecorator({ const profile = await profileResolver?.resolve(); const namePrefix = await namePrefixResolver?.resolve(); pluginInstances[id].setAuth(region, accessKeyId, secretAccessKey, sessionToken, profile, namePrefix); + // cacheTtl is resolved at runtime so it can be dynamic (e.g., cacheTtl=if(forEnv(dev), "1h")) + const cacheTtl = await cacheTtlResolver?.resolve(); + if (cacheTtl !== undefined && cacheTtl !== false && cacheTtl !== '' + && (typeof cacheTtl === 'string' || typeof cacheTtl === 'number') + ) { + pluginInstances[id].cacheTtl = cacheTtl; + } }, }); @@ -598,6 +618,19 @@ plugin.registerResolverFunction({ // Apply namePrefix const finalSecretId = selectedInstance.applyNamePrefix(secretId); + // check cache if cacheTtl is configured and cache is available + if (selectedInstance.cacheTtl !== undefined && pluginCache) { + const cacheKey = `awsSecret:${instanceId}:${finalSecretId}`; + const cached = await pluginCache.get(cacheKey); + if (cached !== undefined) { + debug('cache hit for %s', cacheKey); + return cached; + } + const secretValue = await selectedInstance.getSecret(finalSecretId, jsonKey); + await pluginCache.set(cacheKey, secretValue, selectedInstance.cacheTtl); + return secretValue; + } + const secretValue = await selectedInstance.getSecret(finalSecretId, jsonKey); return secretValue; }, @@ -717,6 +750,19 @@ plugin.registerResolverFunction({ // Apply namePrefix const finalParameterName = selectedInstance.applyNamePrefix(parameterName); + // check cache if cacheTtl is configured and cache is available + if (selectedInstance.cacheTtl !== undefined && pluginCache) { + const cacheKey = `awsParam:${instanceId}:${finalParameterName}`; + const cached = await pluginCache.get(cacheKey); + if (cached !== undefined) { + debug('cache hit for %s', cacheKey); + return cached; + } + const parameterValue = await selectedInstance.getParameter(finalParameterName, jsonKey); + await pluginCache.set(cacheKey, parameterValue, selectedInstance.cacheTtl); + return parameterValue; + } + const parameterValue = await selectedInstance.getParameter(finalParameterName, jsonKey); return parameterValue; }, diff --git a/packages/plugins/bitwarden/src/plugin.ts b/packages/plugins/bitwarden/src/plugin.ts index 31a6a61b..b46e6e09 100644 --- a/packages/plugins/bitwarden/src/plugin.ts +++ b/packages/plugins/bitwarden/src/plugin.ts @@ -1,4 +1,4 @@ -import { type Resolver, plugin } from 'varlock/plugin-lib'; +import { type Resolver, type PluginCacheAccessor, plugin } from 'varlock/plugin-lib'; import ky from 'ky'; import { Buffer } from 'node:buffer'; import { webcrypto } from 'node:crypto'; @@ -13,6 +13,15 @@ const BITWARDEN_ICON = 'simple-icons:bitwarden'; plugin.name = 'bitwarden'; const { debug } = plugin; debug('init - version =', plugin.version); + +// capture cache accessor while the plugin proxy context is active +// (the `plugin` proxy is only valid during module initialization, not during resolve()) +let pluginCache: PluginCacheAccessor | undefined; +try { + pluginCache = plugin.cache; +} catch { + // cache not available (e.g., no encryption key) +} plugin.icon = BITWARDEN_ICON; plugin.standardVars = { initDecorator: '@initBitwarden', @@ -57,6 +66,9 @@ class BitwardenPluginInstance { /** In-flight auth promise - prevents parallel resolution from triggering multiple auth requests (rate limit fix) */ private authInFlight?: Promise; + /** optional cache TTL - when set, resolved values are cached */ + cacheTtl?: string | number; + constructor( readonly id: string, ) {} @@ -336,6 +348,7 @@ plugin.registerRootDecorator({ apiUrl, identityUrl, accessTokenResolver: objArgs.accessToken, + cacheTtlResolver: objArgs.cacheTtl, }; }, async execute({ @@ -343,6 +356,7 @@ plugin.registerRootDecorator({ apiUrl, identityUrl, accessTokenResolver, + cacheTtlResolver, }) { // even if the token is empty, we can't throw errors yet // in case the instance is never actually used @@ -353,6 +367,14 @@ plugin.registerRootDecorator({ apiUrl, identityUrl, ); + + // cacheTtl is resolved at runtime so it can be dynamic (e.g., cacheTtl=if(forEnv(dev), "1h")) + const cacheTtl = await cacheTtlResolver?.resolve(); + if (cacheTtl !== undefined && cacheTtl !== false && cacheTtl !== '' + && (typeof cacheTtl === 'string' || typeof cacheTtl === 'number') + ) { + pluginInstances[id].cacheTtl = cacheTtl; + } }, }); @@ -485,7 +507,19 @@ plugin.registerResolverFunction({ }); } - const secretValue = await selectedInstance.getSecret(secretId); - return secretValue; + // check cache if cacheTtl is configured and cache is available + if (selectedInstance.cacheTtl !== undefined && pluginCache) { + const cacheKey = `bw:${instanceId}:${secretId}`; + const cached = await pluginCache.get(cacheKey); + if (cached !== undefined) { + debug('cache hit for %s', cacheKey); + return cached; + } + const secretValue = await selectedInstance.getSecret(secretId); + await pluginCache.set(cacheKey, secretValue, selectedInstance.cacheTtl); + return secretValue; + } + + return await selectedInstance.getSecret(secretId); }, }); diff --git a/packages/plugins/google-secret-manager/src/plugin.ts b/packages/plugins/google-secret-manager/src/plugin.ts index c5764fc0..e5665146 100644 --- a/packages/plugins/google-secret-manager/src/plugin.ts +++ b/packages/plugins/google-secret-manager/src/plugin.ts @@ -1,4 +1,4 @@ -import { type Resolver, plugin } from 'varlock/plugin-lib'; +import { type Resolver, type PluginCacheAccessor, plugin } from 'varlock/plugin-lib'; import { GoogleAuth } from 'google-auth-library'; @@ -9,6 +9,14 @@ const GSM_ICON = 'devicon:googlecloud'; plugin.name = 'gsm'; const { debug } = plugin; debug('init - version =', plugin.version); +// capture cache accessor while the plugin proxy context is active +// (the `plugin` proxy is only valid during module initialization, not during resolve()) +let pluginCache: PluginCacheAccessor | undefined; +try { + pluginCache = plugin.cache; +} catch { + // cache not available (e.g., no encryption key) +} plugin.icon = GSM_ICON; plugin.standardVars = { initDecorator: '@initGsm', @@ -21,6 +29,8 @@ plugin.standardVars = { class GsmPluginInstance { private projectId?: string; private credentials?: any; + /** optional cache TTL - when set, resolved values are cached */ + cacheTtl?: string | number; constructor( readonly id: string, @@ -187,16 +197,24 @@ plugin.registerRootDecorator({ return { id, + cacheTtlResolver: objArgs.cacheTtl, projectIdResolver: objArgs.projectId, credentialsResolver: objArgs.credentials, }; }, async execute({ - id, projectIdResolver, credentialsResolver, + id, cacheTtlResolver, projectIdResolver, credentialsResolver, }) { const projectId = await projectIdResolver?.resolve(); const credentials = await credentialsResolver?.resolve(); pluginInstances[id].setAuth(projectId, credentials); + // cacheTtl is resolved at runtime so it can be dynamic (e.g., cacheTtl=if(forEnv(dev), "1h")) + const cacheTtl = await cacheTtlResolver?.resolve(); + if (cacheTtl !== undefined && cacheTtl !== false && cacheTtl !== '' + && (typeof cacheTtl === 'string' || typeof cacheTtl === 'number') + ) { + pluginInstances[id].cacheTtl = cacheTtl; + } }, }); @@ -333,7 +351,19 @@ plugin.registerResolverFunction({ throw new SchemaError('No secret reference provided'); } - const secretValue = await selectedInstance.readSecret(secretRef); - return secretValue; + // check cache if cacheTtl is configured and cache is available + if (selectedInstance.cacheTtl !== undefined && pluginCache) { + const cacheKey = `gsm:${instanceId}:${secretRef}`; + const cached = await pluginCache.get(cacheKey); + if (cached !== undefined) { + debug('cache hit for %s', cacheKey); + return cached; + } + const secretValue = await selectedInstance.readSecret(secretRef); + await pluginCache.set(cacheKey, secretValue, selectedInstance.cacheTtl); + return secretValue; + } + + return await selectedInstance.readSecret(secretRef); }, }); From 0a5bbadca2aa9f78beb9472d49c9b02b08fa8ebc Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 9 Apr 2026 14:52:12 -0700 Subject: [PATCH 8/8] validate cacheTtl at plugin init and surface as plugin warning Extract resolveCacheTtl() helper that validates the TTL format during the plugin's execute() phase. Invalid values (like "xyz") now show as plugin-level warnings rather than surfacing as per-item resolution errors. All 4 plugins (1password, aws-secrets, bitwarden, gsm) updated. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/plugins/1password/src/plugin.ts | 12 +++---- packages/plugins/aws-secrets/src/plugin.ts | 11 +++--- packages/plugins/bitwarden/src/plugin.ts | 11 +++--- .../google-secret-manager/src/plugin.ts | 11 +++--- .../varlock/src/cli/helpers/error-checks.ts | 11 ++++++ packages/varlock/src/env-graph/lib/plugins.ts | 4 +++ packages/varlock/src/lib/cache/index.ts | 1 + .../src/lib/cache/resolve-cache-ttl.ts | 34 +++++++++++++++++++ packages/varlock/src/plugin-lib.ts | 1 + 9 files changed, 71 insertions(+), 25 deletions(-) create mode 100644 packages/varlock/src/lib/cache/resolve-cache-ttl.ts diff --git a/packages/plugins/1password/src/plugin.ts b/packages/plugins/1password/src/plugin.ts index df22199e..edd9a04f 100644 --- a/packages/plugins/1password/src/plugin.ts +++ b/packages/plugins/1password/src/plugin.ts @@ -1,4 +1,6 @@ -import { type Resolver, type PluginCacheAccessor, plugin } from 'varlock/plugin-lib'; +import { + type Resolver, type PluginCacheAccessor, plugin, resolveCacheTtl, +} from 'varlock/plugin-lib'; import { createDeferredPromise, type DeferredPromise } from '@env-spec/utils/defer'; import { Client, createClient } from '@1password/sdk'; @@ -461,11 +463,8 @@ plugin.registerRootDecorator({ connectHost, connectToken as string | undefined, ); - // cacheTtl is resolved at runtime so it can be dynamic (e.g., cacheTtl=if(forEnv(dev), "1h")) - const cacheTtl = await cacheTtlResolver?.resolve(); - if (cacheTtl !== undefined && cacheTtl !== false && cacheTtl !== '' - && (typeof cacheTtl === 'string' || typeof cacheTtl === 'number') - ) { + const cacheTtl = await resolveCacheTtl(cacheTtlResolver); + if (cacheTtl !== undefined) { pluginInstances[id].cacheTtl = cacheTtl; } }, @@ -482,7 +481,6 @@ plugin.registerDataType({ description: '1Password service accounts', url: 'https://developer.1password.com/docs/service-accounts/', }, - 'https://example.com', ], async validate(val) { if (!val.startsWith('ops_')) { diff --git a/packages/plugins/aws-secrets/src/plugin.ts b/packages/plugins/aws-secrets/src/plugin.ts index efca4384..cd0eb3e4 100644 --- a/packages/plugins/aws-secrets/src/plugin.ts +++ b/packages/plugins/aws-secrets/src/plugin.ts @@ -1,4 +1,6 @@ -import { type Resolver, type PluginCacheAccessor, plugin } from 'varlock/plugin-lib'; +import { + type Resolver, type PluginCacheAccessor, plugin, resolveCacheTtl, +} from 'varlock/plugin-lib'; import { SecretsManagerClient, @@ -454,11 +456,8 @@ plugin.registerRootDecorator({ const profile = await profileResolver?.resolve(); const namePrefix = await namePrefixResolver?.resolve(); pluginInstances[id].setAuth(region, accessKeyId, secretAccessKey, sessionToken, profile, namePrefix); - // cacheTtl is resolved at runtime so it can be dynamic (e.g., cacheTtl=if(forEnv(dev), "1h")) - const cacheTtl = await cacheTtlResolver?.resolve(); - if (cacheTtl !== undefined && cacheTtl !== false && cacheTtl !== '' - && (typeof cacheTtl === 'string' || typeof cacheTtl === 'number') - ) { + const cacheTtl = await resolveCacheTtl(cacheTtlResolver); + if (cacheTtl !== undefined) { pluginInstances[id].cacheTtl = cacheTtl; } }, diff --git a/packages/plugins/bitwarden/src/plugin.ts b/packages/plugins/bitwarden/src/plugin.ts index b46e6e09..2a4978e6 100644 --- a/packages/plugins/bitwarden/src/plugin.ts +++ b/packages/plugins/bitwarden/src/plugin.ts @@ -1,4 +1,6 @@ -import { type Resolver, type PluginCacheAccessor, plugin } from 'varlock/plugin-lib'; +import { + type Resolver, type PluginCacheAccessor, plugin, resolveCacheTtl, +} from 'varlock/plugin-lib'; import ky from 'ky'; import { Buffer } from 'node:buffer'; import { webcrypto } from 'node:crypto'; @@ -368,11 +370,8 @@ plugin.registerRootDecorator({ identityUrl, ); - // cacheTtl is resolved at runtime so it can be dynamic (e.g., cacheTtl=if(forEnv(dev), "1h")) - const cacheTtl = await cacheTtlResolver?.resolve(); - if (cacheTtl !== undefined && cacheTtl !== false && cacheTtl !== '' - && (typeof cacheTtl === 'string' || typeof cacheTtl === 'number') - ) { + const cacheTtl = await resolveCacheTtl(cacheTtlResolver); + if (cacheTtl !== undefined) { pluginInstances[id].cacheTtl = cacheTtl; } }, diff --git a/packages/plugins/google-secret-manager/src/plugin.ts b/packages/plugins/google-secret-manager/src/plugin.ts index e5665146..409f50bc 100644 --- a/packages/plugins/google-secret-manager/src/plugin.ts +++ b/packages/plugins/google-secret-manager/src/plugin.ts @@ -1,4 +1,6 @@ -import { type Resolver, type PluginCacheAccessor, plugin } from 'varlock/plugin-lib'; +import { + type Resolver, type PluginCacheAccessor, plugin, resolveCacheTtl, +} from 'varlock/plugin-lib'; import { GoogleAuth } from 'google-auth-library'; @@ -208,11 +210,8 @@ plugin.registerRootDecorator({ const projectId = await projectIdResolver?.resolve(); const credentials = await credentialsResolver?.resolve(); pluginInstances[id].setAuth(projectId, credentials); - // cacheTtl is resolved at runtime so it can be dynamic (e.g., cacheTtl=if(forEnv(dev), "1h")) - const cacheTtl = await cacheTtlResolver?.resolve(); - if (cacheTtl !== undefined && cacheTtl !== false && cacheTtl !== '' - && (typeof cacheTtl === 'string' || typeof cacheTtl === 'number') - ) { + const cacheTtl = await resolveCacheTtl(cacheTtlResolver); + if (cacheTtl !== undefined) { pluginInstances[id].cacheTtl = cacheTtl; } }, diff --git a/packages/varlock/src/cli/helpers/error-checks.ts b/packages/varlock/src/cli/helpers/error-checks.ts index 6600207d..c2c3d6b2 100644 --- a/packages/varlock/src/cli/helpers/error-checks.ts +++ b/packages/varlock/src/cli/helpers/error-checks.ts @@ -65,6 +65,17 @@ export function checkForSchemaErrors(envGraph: EnvGraph) { } return gracefulExit(1); } + + // check for errors from decorator execute() (e.g., invalid plugin options like cacheTtl) + if (source.resolutionErrors.length) { + console.error(`🚨 Error(s) during initialization of ${source.label}`); + + for (const resErr of source.resolutionErrors) { + console.error(`- ${resErr.message}`); + showErrorLocationDetails(resErr); + } + return gracefulExit(1); + } } // now we check for any schema errors - where something about how things are wired up is invalid diff --git a/packages/varlock/src/env-graph/lib/plugins.ts b/packages/varlock/src/env-graph/lib/plugins.ts index 35015056..6e486fca 100644 --- a/packages/varlock/src/env-graph/lib/plugins.ts +++ b/packages/varlock/src/env-graph/lib/plugins.ts @@ -16,6 +16,8 @@ import { pathExists } from '@env-spec/utils/fs-utils'; import { getUserVarlockDir } from '../../lib/user-config-dir'; import { PluginCacheAccessor } from '../../lib/cache/plugin-cache-accessor'; import type { CacheStore } from '../../lib/cache/cache-store'; +import { parseTtl } from '../../lib/cache/ttl-parser'; +import { resolveCacheTtl } from '../../lib/cache/resolve-cache-ttl'; import { confirm } from '../../cli/helpers/prompts'; @@ -81,6 +83,8 @@ const varlockPluginLibExports = { SchemaError, ResolutionError, createDebug, + parseTtl, + resolveCacheTtl, }; diff --git a/packages/varlock/src/lib/cache/index.ts b/packages/varlock/src/lib/cache/index.ts index a00600f6..e070f9ba 100644 --- a/packages/varlock/src/lib/cache/index.ts +++ b/packages/varlock/src/lib/cache/index.ts @@ -1,3 +1,4 @@ export { CacheStore } from './cache-store'; export { parseTtl } from './ttl-parser'; export { PluginCacheAccessor } from './plugin-cache-accessor'; +export { resolveCacheTtl } from './resolve-cache-ttl'; diff --git a/packages/varlock/src/lib/cache/resolve-cache-ttl.ts b/packages/varlock/src/lib/cache/resolve-cache-ttl.ts new file mode 100644 index 00000000..89dbc62d --- /dev/null +++ b/packages/varlock/src/lib/cache/resolve-cache-ttl.ts @@ -0,0 +1,34 @@ +import { parseTtl } from './ttl-parser'; + +/** + * Resolve and validate a cacheTtl value from a plugin's init decorator. + * + * Returns the validated TTL (string or number) if valid, or undefined if + * the resolver is not set or the resolved value is falsy (disabled). + * + * Throws on invalid TTL format — the error will be caught by the decorator + * execution handler and surfaced as a plugin-level error. + */ +export async function resolveCacheTtl( + cacheTtlResolver: { resolve(): Promise } | undefined, +): Promise { + if (!cacheTtlResolver) return undefined; + + const cacheTtl = await cacheTtlResolver.resolve(); + + // falsy values (false, undefined, '') mean caching is disabled (e.g., conditional) + if (cacheTtl === undefined || cacheTtl === false || cacheTtl === '') { + return undefined; + } + + if (typeof cacheTtl !== 'string' && typeof cacheTtl !== 'number') { + const err = new Error(`cacheTtl resolved to an invalid type (${typeof cacheTtl})`); + (err as any).tip = 'cacheTtl should resolve to a string like "1h" or a number (0 = forever)'; + throw err; + } + + // validate the format — parseTtl throws on invalid input + parseTtl(cacheTtl); + + return cacheTtl; +} diff --git a/packages/varlock/src/plugin-lib.ts b/packages/varlock/src/plugin-lib.ts index 8c5720fd..f196c52d 100644 --- a/packages/varlock/src/plugin-lib.ts +++ b/packages/varlock/src/plugin-lib.ts @@ -4,6 +4,7 @@ import { pluginProxy } from './plugin-context'; export type { Resolver } from './env-graph/lib/resolver'; export type { PluginCacheAccessor } from './lib/cache/plugin-cache-accessor'; export { parseTtl } from './lib/cache/ttl-parser'; +export { resolveCacheTtl } from './lib/cache/resolve-cache-ttl'; export { createDebug, type Debugger } from './lib/debug'; // Error classes exported directly so plugin authors can import them without