Skip to content

Commit 0ea6641

Browse files
theoephraimclaude
andauthored
Add varlock explain command and override indicators in load output (#560)
Users were confused when setting config items via `op()` but never seeing the function run because a process.env override was taking precedence. - Show override indicator (🟡) in `varlock load` pretty output when a value comes from process.env rather than file definitions - Add `varlock explain ITEM_KEY` command showing all definitions, sources, active resolver, override status, decorators, and docs for an item Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6ab2d31 commit 0ea6641

5 files changed

Lines changed: 239 additions & 8 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"varlock": minor
3+
---
4+
5+
Add `varlock explain ITEM_KEY` command and override indicators in `varlock load` output.
6+
7+
**Override indicators**: When a config item's value comes from a `process.env` override rather than its file-based definitions, `varlock load` now shows a yellow indicator on that item. This helps users understand why their resolver functions (e.g. `op()`) are not being called.
8+
9+
**`varlock explain` command**: Shows detailed information about how a single config item is resolved, including all definitions and sources in priority order, which source is active, whether a process.env override is in effect (and what would be used without it), decorators, type info, and documentation links.

packages/varlock/src/cli/cli-executable.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { commandSpec as printenvCommandSpec } from './commands/printenv.command'
1919
// import { commandSpec as doctorCommandSpec } from './commands/doctor.command';
2020
import { commandSpec as helpCommandSpec } from './commands/help.command';
2121
import { commandSpec as telemetryCommandSpec } from './commands/telemetry.command';
22+
import { commandSpec as explainCommandSpec } from './commands/explain.command';
2223
import { commandSpec as scanCommandSpec } from './commands/scan.command';
2324
import { commandSpec as typegenCommandSpec } from './commands/typegen.command';
2425
// import { commandSpec as loginCommandSpec } from './commands/login.command';
@@ -52,6 +53,7 @@ subCommands.set('run', buildLazyCommand(runCommandSpec, async () => await import
5253
subCommands.set('printenv', buildLazyCommand(printenvCommandSpec, async () => await import('./commands/printenv.command')));
5354
// subCommands.set('encrypt', buildLazyCommand(encryptCommandSpec, async () => await import('./commands/encrypt.command')));
5455
// subCommands.set('doctor', buildLazyCommand(doctorCommandSpec, async () => await import('./commands/doctor.command')));
56+
subCommands.set('explain', buildLazyCommand(explainCommandSpec, async () => await import('./commands/explain.command')));
5557
subCommands.set('help', buildLazyCommand(helpCommandSpec, async () => await import('./commands/help.command')));
5658
subCommands.set('telemetry', buildLazyCommand(telemetryCommandSpec, async () => await import('./commands/telemetry.command')));
5759
subCommands.set('scan', buildLazyCommand(scanCommandSpec, async () => await import('./commands/scan.command')));
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import ansis from 'ansis';
2+
import { define } from 'gunshi';
3+
import { gracefulExit } from 'exit-hook';
4+
5+
import { loadVarlockEnvGraph } from '../../lib/load-graph';
6+
import { formattedValue } from '../../lib/formatting';
7+
import { redactString } from '../../runtime/lib/redaction';
8+
import {
9+
checkForSchemaErrors, checkForNoEnvFiles,
10+
} from '../helpers/error-checks';
11+
import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils';
12+
import { CliExitError } from '../helpers/exit-error';
13+
import { StaticValueResolver } from '../../env-graph/lib/resolver';
14+
import _ from '@env-spec/utils/my-dash';
15+
16+
export const commandSpec = define({
17+
name: 'explain',
18+
description: 'Show detailed information about how a config item is resolved',
19+
args: {
20+
env: {
21+
type: 'string',
22+
description: 'Set the environment (e.g., production, development, etc)',
23+
},
24+
path: {
25+
type: 'string',
26+
short: 'p',
27+
description: 'Path to a specific .env file or directory to use as the entry point',
28+
},
29+
},
30+
examples: `
31+
Shows detailed information about all definitions, sources, and overrides
32+
that feed into a single config item. Useful for debugging why a value
33+
is not what you expect.
34+
35+
Examples:
36+
varlock explain DATABASE_URL # Explain how DATABASE_URL is resolved
37+
varlock explain --env production API_KEY # Explain in production context
38+
`.trim(),
39+
});
40+
41+
function describeResolver(resolver: any, indent = ''): string {
42+
if (resolver instanceof StaticValueResolver) {
43+
return `${indent}static value`;
44+
}
45+
const fnName = resolver.fnName;
46+
if (fnName && !fnName.startsWith('\0')) {
47+
return `${indent}${fnName}()`;
48+
}
49+
return `${indent}(resolver)`;
50+
}
51+
52+
export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) => {
53+
const positionals = (ctx.positionals ?? []).slice(ctx.commandPath?.length ?? 0);
54+
if (!positionals.length) {
55+
throw new CliExitError('Missing required argument: variable name', {
56+
suggestion: 'Run `varlock explain MY_VAR` to explain a config item',
57+
});
58+
}
59+
const varName = positionals[0];
60+
61+
const envGraph = await loadVarlockEnvGraph({
62+
currentEnvFallback: ctx.values.env,
63+
entryFilePath: ctx.values.path,
64+
});
65+
66+
checkForSchemaErrors(envGraph);
67+
checkForNoEnvFiles(envGraph);
68+
69+
if (!(varName in envGraph.configSchema)) {
70+
throw new CliExitError(`Variable "${varName}" not found in schema`);
71+
}
72+
73+
await envGraph.resolveEnvValues();
74+
75+
const item = envGraph.configSchema[varName];
76+
const isSensitive = item.isSensitive;
77+
78+
// Header
79+
console.log('');
80+
console.log(ansis.bold.cyan(` ${item.key}`));
81+
console.log('');
82+
83+
// Description
84+
if (item.description) {
85+
console.log(` ${ansis.gray('Description:')} ${item.description}`);
86+
}
87+
88+
// Type info
89+
if (item.dataType) {
90+
console.log(` ${ansis.gray('Type:')} ${item.dataType.name}`);
91+
}
92+
93+
// Properties
94+
const props = [];
95+
if (item.isRequired) props.push('required');
96+
else props.push('optional');
97+
if (isSensitive) props.push('sensitive');
98+
else props.push('public');
99+
console.log(` ${ansis.gray('Properties:')} ${props.join(', ')}`);
100+
101+
// Resolved value
102+
console.log('');
103+
console.log(ansis.bold(' Resolved value'));
104+
if (item.validationState === 'error') {
105+
console.log(` ${ansis.red(' (resolution failed)')}`);
106+
for (const err of item.errors) {
107+
console.log(` ${ansis.red(` - ${err.message}`)}`);
108+
}
109+
} else {
110+
let valStr = formattedValue(item.resolvedValue, true);
111+
if (isSensitive && item.resolvedValue && _.isString(item.resolvedValue)) {
112+
valStr = redactString(item.resolvedValue)!;
113+
}
114+
console.log(` ${valStr}`);
115+
if (item.isCoerced) {
116+
let rawStr = formattedValue(item.resolvedRawValue, true);
117+
if (isSensitive && item.resolvedRawValue && _.isString(item.resolvedRawValue)) {
118+
rawStr = redactString(item.resolvedRawValue)!;
119+
}
120+
console.log(` ${ansis.gray.italic(`coerced from ${rawStr}`)}`);
121+
}
122+
}
123+
124+
// Value source
125+
console.log('');
126+
console.log(ansis.bold(' Value source'));
127+
128+
if (item.isOverridden) {
129+
console.log(` ${ansis.yellow('⚡ process.env override')} ${ansis.yellow.bold('(active)')}`);
130+
const activeValueDef = item.activeValueDef;
131+
if (activeValueDef) {
132+
const sourceLabel = activeValueDef.source?.label || 'internal';
133+
const resolverDesc = activeValueDef.itemDef.resolver
134+
? describeResolver(activeValueDef.itemDef.resolver)
135+
: 'no value';
136+
console.log(` ${ansis.gray('└')} ${ansis.gray.italic(`would use ${resolverDesc} from ${sourceLabel} without override`)}`);
137+
}
138+
} else {
139+
const activeValueDef = item.activeValueDef;
140+
if (activeValueDef) {
141+
const sourceLabel = activeValueDef.source?.label || 'internal';
142+
const resolverDesc = activeValueDef.itemDef.resolver
143+
? describeResolver(activeValueDef.itemDef.resolver)
144+
: 'no value';
145+
console.log(` ${resolverDesc} from ${ansis.cyan(sourceLabel)}`);
146+
} else {
147+
console.log(` ${ansis.gray('(no value set)')}`);
148+
}
149+
}
150+
151+
// All definitions
152+
const defs = item.defs;
153+
if (defs.length) {
154+
console.log('');
155+
console.log(ansis.bold(' All definitions') + ansis.gray(` (${defs.length} source${defs.length > 1 ? 's' : ''}, highest priority first)`));
156+
157+
for (let i = 0; i < defs.length; i++) {
158+
const def = defs[i];
159+
const sourceLabel = def.source?.label || 'internal (builtin)';
160+
const sourceType = def.source?.type || 'builtin';
161+
162+
const isActiveSource = !item.isOverridden && def === item.activeValueDef;
163+
const marker = isActiveSource ? ansis.green(' ← active') : '';
164+
165+
console.log(` ${ansis.gray(`${i + 1}.`)} ${ansis.cyan(sourceLabel)} ${ansis.gray(`(${sourceType})`)}${marker}`);
166+
167+
// Show resolver info
168+
if (def.itemDef.resolver) {
169+
console.log(` ${ansis.gray('value:')} ${describeResolver(def.itemDef.resolver)}`);
170+
} else {
171+
console.log(` ${ansis.gray('value:')} ${ansis.gray.italic('(none - decorators only)')}`);
172+
}
173+
174+
// Show decorators from this definition
175+
const decNames = def.itemDef.decorators?.map((d) => `@${d.name}`).join(', ');
176+
if (decNames) {
177+
console.log(` ${ansis.gray('decorators:')} ${ansis.magenta(decNames)}`);
178+
}
179+
}
180+
}
181+
182+
// Docs links
183+
const docsLinks = item.docsLinks;
184+
if (docsLinks.length) {
185+
console.log('');
186+
console.log(ansis.bold(' Documentation'));
187+
for (const link of docsLinks) {
188+
const label = link.description ? `${link.description}: ` : '';
189+
console.log(` ${label}${ansis.underline(link.url)}`);
190+
}
191+
}
192+
193+
console.log('');
194+
195+
if (item.validationState === 'error') {
196+
gracefulExit(1);
197+
}
198+
};

packages/varlock/src/env-graph/lib/config-item.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,35 @@ export class ConfigItem {
113113
return links;
114114
}
115115

116+
/** Whether the resolved value came from a process.env override rather than file definitions */
117+
get isOverridden() {
118+
return this.key in this.envGraph.overrideValues;
119+
}
120+
121+
/**
122+
* Returns the definition+source that would provide the value resolver
123+
* (i.e. the "winning" definition), or undefined if no definition has a resolver.
124+
* This is useful for explaining where a value comes from regardless of overrides.
125+
*/
126+
get activeValueDef(): ConfigItemDefAndSource | undefined {
127+
const hasInternalResolver = this._internalDefs.some((d) => d.itemDef.resolver);
128+
for (const def of this.defs) {
129+
if (def.itemDef.resolver) {
130+
if (
131+
hasInternalResolver
132+
&& def.itemDef.resolver instanceof StaticValueResolver
133+
&& !def.itemDef.resolver.staticValue
134+
) {
135+
continue;
136+
}
137+
return def;
138+
}
139+
}
140+
}
141+
116142
get valueResolver() {
117143
// special case for process.env overrides - always return the static value
118-
if (this.key in this.envGraph.overrideValues) {
144+
if (this.isOverridden) {
119145
return new StaticValueResolver(this.envGraph.overrideValues[this.key]);
120146
}
121147

packages/varlock/src/lib/formatting.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,9 @@ export function getItemSummary(item: ConfigItem) {
118118
),
119119
]));
120120

121-
// if (item.overrides?.length) {
122-
// const activeOverride = item.overrides[0];
123-
// let overrideNote = ansis.gray.italic('value set via override: ');
124-
// overrideNote += ansis.gray(activeOverride.sourceType);
125-
// if (activeOverride.sourceLabel) overrideNote += ansis.gray(` - ${activeOverride.sourceLabel}`);
126-
// summary.push(` ${overrideNote}`);
127-
// }
121+
if (item.isOverridden) {
122+
summary.push(` 🟡 ${ansis.yellow.italic('set via process.env override')}`);
123+
}
128124

129125
itemErrors?.forEach((err) => {
130126
summary.push(ansis[err.isWarning ? 'yellow' : 'red'](` - ${err.isWarning ? '[WARNING] ' : ''}${err.message}`));

0 commit comments

Comments
 (0)