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/plugins/1password/src/plugin.ts b/packages/plugins/1password/src/plugin.ts index 7e57edbe..edd9a04f 100644 --- a/packages/plugins/1password/src/plugin.ts +++ b/packages/plugins/1password/src/plugin.ts @@ -1,4 +1,6 @@ -import { type Resolver, 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'; @@ -12,6 +14,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 +106,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 +442,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 +463,10 @@ plugin.registerRootDecorator({ connectHost, connectToken as string | undefined, ); + const cacheTtl = await resolveCacheTtl(cacheTtlResolver); + if (cacheTtl !== undefined) { + pluginInstances[id].cacheTtl = cacheTtl; + } }, }); @@ -463,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_')) { @@ -541,8 +558,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 +632,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/plugins/aws-secrets/src/plugin.ts b/packages/plugins/aws-secrets/src/plugin.ts index 92ed43e0..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, plugin } from 'varlock/plugin-lib'; +import { + type Resolver, type PluginCacheAccessor, plugin, resolveCacheTtl, +} from 'varlock/plugin-lib'; import { SecretsManagerClient, @@ -21,6 +23,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 +56,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 +436,7 @@ plugin.registerRootDecorator({ secretAccessKeyResolver: objArgs.secretAccessKey, sessionTokenResolver: objArgs.sessionToken, namePrefixResolver: objArgs.namePrefix, + cacheTtlResolver: objArgs.cacheTtl, }; }, async execute({ @@ -433,6 +447,7 @@ plugin.registerRootDecorator({ secretAccessKeyResolver, sessionTokenResolver, namePrefixResolver, + cacheTtlResolver, }) { const region = await regionResolver.resolve(); const accessKeyId = await accessKeyIdResolver?.resolve(); @@ -441,6 +456,10 @@ plugin.registerRootDecorator({ const profile = await profileResolver?.resolve(); const namePrefix = await namePrefixResolver?.resolve(); pluginInstances[id].setAuth(region, accessKeyId, secretAccessKey, sessionToken, profile, namePrefix); + const cacheTtl = await resolveCacheTtl(cacheTtlResolver); + if (cacheTtl !== undefined) { + pluginInstances[id].cacheTtl = cacheTtl; + } }, }); @@ -598,6 +617,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 +749,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..2a4978e6 100644 --- a/packages/plugins/bitwarden/src/plugin.ts +++ b/packages/plugins/bitwarden/src/plugin.ts @@ -1,4 +1,6 @@ -import { type Resolver, 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'; @@ -13,6 +15,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 +68,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 +350,7 @@ plugin.registerRootDecorator({ apiUrl, identityUrl, accessTokenResolver: objArgs.accessToken, + cacheTtlResolver: objArgs.cacheTtl, }; }, async execute({ @@ -343,6 +358,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 +369,11 @@ plugin.registerRootDecorator({ apiUrl, identityUrl, ); + + const cacheTtl = await resolveCacheTtl(cacheTtlResolver); + if (cacheTtl !== undefined) { + pluginInstances[id].cacheTtl = cacheTtl; + } }, }); @@ -485,7 +506,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..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, plugin } from 'varlock/plugin-lib'; +import { + type Resolver, type PluginCacheAccessor, plugin, resolveCacheTtl, +} from 'varlock/plugin-lib'; import { GoogleAuth } from 'google-auth-library'; @@ -9,6 +11,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 +31,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 +199,21 @@ 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); + const cacheTtl = await resolveCacheTtl(cacheTtlResolver); + if (cacheTtl !== undefined) { + pluginInstances[id].cacheTtl = cacheTtl; + } }, }); @@ -333,7 +350,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); }, }); 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-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..27c70c77 --- /dev/null +++ b/packages/varlock/src/cli/commands/cache.command.ts @@ -0,0 +1,206 @@ +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({ + 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 # 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]; + + if (!localEncrypt.keyExists()) { + console.log(ansis.gray(' No encryption key found — cache is not active.')); + return; + } + + const store = new CacheStore(); + + // non-interactive clear + 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`); + } + 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/cli/commands/explain.command.ts b/packages/varlock/src/cli/commands/explain.command.ts index 9126b3bf..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 } from '../../lib/formatting'; +import { formattedValue, formatTimeAgo, formatDuration } from '../../lib/formatting'; import { redactString } from '../../runtime/lib/redaction'; import { checkForSchemaErrors, checkForNoEnvFiles, @@ -148,6 +148,27 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = } } + // Cache info + if (item.isCached || item.isCacheHit) { + console.log(''); + console.log(ansis.bold(' Cache')); + + if (item.isCacheHit) { + 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)`); + } + } + // 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/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/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..6e486fca 100644 --- a/packages/varlock/src/env-graph/lib/plugins.ts +++ b/packages/varlock/src/env-graph/lib/plugins.ts @@ -14,6 +14,10 @@ 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 { parseTtl } from '../../lib/cache/ttl-parser'; +import { resolveCacheTtl } from '../../lib/cache/resolve-cache-ttl'; import { confirm } from '../../cli/helpers/prompts'; @@ -79,6 +83,8 @@ const varlockPluginLibExports = { SchemaError, ResolutionError, createDebug, + parseTtl, + resolveCacheTtl, }; @@ -209,6 +215,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 +450,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..8747e5bc --- /dev/null +++ b/packages/varlock/src/env-graph/lib/resolution-context.ts @@ -0,0 +1,37 @@ +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; + expiresAt: 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..9f197a18 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, expiresAt: cached.expiresAt }); + 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..6b028951 --- /dev/null +++ b/packages/varlock/src/lib/cache/cache-store.ts @@ -0,0 +1,234 @@ +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; expiresAt: 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, expiresAt: entry.e }; + } 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, + }; + } + + /** + * 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). + */ + 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..e070f9ba --- /dev/null +++ b/packages/varlock/src/lib/cache/index.ts @@ -0,0 +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/plugin-cache-accessor.ts b/packages/varlock/src/lib/cache/plugin-cache-accessor.ts new file mode 100644 index 00000000..d91849d0 --- /dev/null +++ b/packages/varlock/src/lib/cache/plugin-cache-accessor.ts @@ -0,0 +1,56 @@ +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. + * + * 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'); + * 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 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, expiresAt: result.expiresAt }); + } catch { + // resolution context not available — that's fine + } + } + 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/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/lib/cache/ttl-parser.test.ts b/packages/varlock/src/lib/cache/ttl-parser.test.ts new file mode 100644 index 00000000..7203c339 --- /dev/null +++ b/packages/varlock/src/lib/cache/ttl-parser.test.ts @@ -0,0 +1,89 @@ +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); + }); + 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', () => { + 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..56898a1e --- /dev/null +++ b/packages/varlock/src/lib/cache/ttl-parser.ts @@ -0,0 +1,82 @@ +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) */ +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*([a-z]+)$/i); + if (!match) { + throw new Error( + `Invalid TTL: "${ttl}" — expected a number with a unit suffix (e.g. "1h", "30m", "1hr", "2days")`, + ); + } + + const value = parseFloat(match[1]); + 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); +} diff --git a/packages/varlock/src/lib/formatting.ts b/packages/varlock/src/lib/formatting.ts index 2f226484..a2075300 100644 --- a/packages/varlock/src/lib/formatting.ts +++ b/packages/varlock/src/lib/formatting.ts @@ -88,6 +88,32 @@ 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); + 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; @@ -109,6 +135,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, @@ -116,12 +152,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.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}`)); 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..f196c52d 100644 --- a/packages/varlock/src/plugin-lib.ts +++ b/packages/varlock/src/plugin-lib.ts @@ -2,6 +2,9 @@ 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 { 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