Skip to content

Commit 7de0e68

Browse files
committed
add reveal command
1 parent 7cd8d35 commit 7de0e68

2 files changed

Lines changed: 224 additions & 0 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { commandSpec as runCommandSpec } from './commands/run.command';
1717
import { commandSpec as printenvCommandSpec } from './commands/printenv.command';
1818
import { commandSpec as encryptCommandSpec } from './commands/encrypt.command';
1919
import { commandSpec as lockCommandSpec } from './commands/lock.command';
20+
import { commandSpec as revealCommandSpec } from './commands/reveal.command';
2021
// import { commandSpec as doctorCommandSpec } from './commands/doctor.command';
2122
import { commandSpec as helpCommandSpec } from './commands/help.command';
2223
import { commandSpec as telemetryCommandSpec } from './commands/telemetry.command';
@@ -55,6 +56,7 @@ subCommands.set('run', buildLazyCommand(runCommandSpec, async () => await import
5556
subCommands.set('printenv', buildLazyCommand(printenvCommandSpec, async () => await import('./commands/printenv.command')));
5657
subCommands.set('encrypt', buildLazyCommand(encryptCommandSpec, async () => await import('./commands/encrypt.command')));
5758
subCommands.set('lock', buildLazyCommand(lockCommandSpec, async () => await import('./commands/lock.command')));
59+
subCommands.set('reveal', buildLazyCommand(revealCommandSpec, async () => await import('./commands/reveal.command')));
5860
// subCommands.set('doctor', buildLazyCommand(doctorCommandSpec, async () => await import('./commands/doctor.command')));
5961
subCommands.set('explain', buildLazyCommand(explainCommandSpec, async () => await import('./commands/explain.command')));
6062
subCommands.set('help', buildLazyCommand(helpCommandSpec, async () => await import('./commands/help.command')));
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import ansis from 'ansis';
2+
import { define } from 'gunshi';
3+
import { isCancel } from '@clack/prompts';
4+
import { gracefulExit } from 'exit-hook';
5+
6+
import { loadVarlockEnvGraph } from '../../lib/load-graph';
7+
import { checkForSchemaErrors, checkForNoEnvFiles } from '../helpers/error-checks';
8+
import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils';
9+
import { CliExitError } from '../helpers/exit-error';
10+
import { select } from '../helpers/prompts';
11+
import { ConfigItem } from '../../env-graph';
12+
import { redactString } from '../../runtime/lib/redaction';
13+
14+
export const commandSpec = define({
15+
name: 'reveal',
16+
description: 'Securely view decrypted values of sensitive environment variables',
17+
args: {
18+
copy: {
19+
type: 'boolean',
20+
description: 'Copy the value to clipboard instead of displaying (auto-clears after 10s)',
21+
},
22+
path: {
23+
type: 'string',
24+
short: 'p',
25+
description: 'Path to a specific .env file or directory to use as the entry point',
26+
},
27+
env: {
28+
type: 'string',
29+
description: 'Set the environment (e.g., production, development, etc)',
30+
},
31+
},
32+
examples: `
33+
Securely view the plaintext value of sensitive environment variables.
34+
Values are shown in an alternate screen buffer so they don't persist in
35+
terminal scrollback history.
36+
37+
Examples:
38+
varlock reveal # Interactive picker to select and reveal values
39+
varlock reveal MY_SECRET # Reveal a specific variable
40+
varlock reveal MY_SECRET --copy # Copy value to clipboard (auto-clears after 10s)
41+
`.trim(),
42+
});
43+
44+
const CLIPBOARD_CLEAR_DELAY_MS = 10_000;
45+
46+
async function copyToClipboard(text: string): Promise<void> {
47+
const { execSync } = await import('node:child_process');
48+
const platform = process.platform;
49+
50+
if (platform === 'darwin') {
51+
execSync('pbcopy', { input: text });
52+
} else if (platform === 'linux') {
53+
// try xclip first, then xsel
54+
try {
55+
execSync('xclip -selection clipboard', { input: text });
56+
} catch {
57+
execSync('xsel --clipboard --input', { input: text });
58+
}
59+
} else if (platform === 'win32') {
60+
execSync('clip', { input: text });
61+
} else {
62+
throw new CliExitError('Clipboard not supported on this platform');
63+
}
64+
}
65+
66+
async function clearClipboard(): Promise<void> {
67+
const { execSync } = await import('node:child_process');
68+
const platform = process.platform;
69+
70+
try {
71+
if (platform === 'darwin') {
72+
execSync('pbcopy', { input: '' });
73+
} else if (platform === 'linux') {
74+
try {
75+
execSync('xclip -selection clipboard', { input: '' });
76+
} catch {
77+
execSync('xsel --clipboard --input', { input: '' });
78+
}
79+
} else if (platform === 'win32') {
80+
execSync('echo. | clip', { shell: 'cmd.exe' });
81+
}
82+
} catch {
83+
// best effort
84+
}
85+
}
86+
87+
function enterAltScreen() {
88+
process.stdout.write('\x1b[?1049h'); // switch to alternate screen buffer
89+
process.stdout.write('\x1b[H'); // move cursor to top-left
90+
}
91+
92+
function exitAltScreen() {
93+
process.stdout.write('\x1b[?1049l'); // switch back to main screen buffer
94+
}
95+
96+
/** Wait for a single keypress, returns the key */
97+
async function waitForKeypress(): Promise<string> {
98+
return new Promise((resolve) => {
99+
const wasRaw = process.stdin.isRaw;
100+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
101+
process.stdin.resume();
102+
process.stdin.once('data', (data) => {
103+
if (process.stdin.isTTY) process.stdin.setRawMode(wasRaw);
104+
process.stdin.pause();
105+
resolve(data.toString());
106+
});
107+
});
108+
}
109+
110+
function displayRevealedValue(item: ConfigItem) {
111+
enterAltScreen();
112+
113+
const value = item.resolvedValue;
114+
const valStr = value === undefined || value === null ? ansis.gray('(empty)') : String(value);
115+
116+
console.log('');
117+
console.log(ansis.bold.cyan(` ${item.key}`));
118+
if (item.description) {
119+
console.log(ansis.gray(` ${item.description}`));
120+
}
121+
console.log('');
122+
console.log(` ${valStr}`);
123+
console.log('');
124+
console.log(ansis.gray(' Press any key to hide...'));
125+
}
126+
127+
export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) => {
128+
const { copy: copyMode } = ctx.values;
129+
130+
const envGraph = await loadVarlockEnvGraph({
131+
currentEnvFallback: ctx.values.env,
132+
entryFilePath: ctx.values.path,
133+
});
134+
135+
checkForSchemaErrors(envGraph);
136+
checkForNoEnvFiles(envGraph);
137+
138+
await envGraph.resolveEnvValues();
139+
140+
// Collect sensitive items
141+
const sensitiveItems: Array<ConfigItem> = [];
142+
for (const itemKey of envGraph.sortedConfigKeys) {
143+
const item = envGraph.configSchema[itemKey];
144+
if (item.isSensitive && item.resolvedValue !== undefined) {
145+
sensitiveItems.push(item);
146+
}
147+
}
148+
149+
if (sensitiveItems.length === 0) {
150+
console.log('No sensitive values found to reveal.');
151+
return;
152+
}
153+
154+
// Check if a specific variable was requested via positional arg
155+
const positionals = (ctx.positionals ?? []).slice(ctx.commandPath?.length ?? 0);
156+
const requestedVar = positionals[0];
157+
158+
if (requestedVar) {
159+
// Direct reveal of a specific variable
160+
const item = sensitiveItems.find((i) => i.key === requestedVar);
161+
if (!item) {
162+
// Check if it exists but isn't sensitive
163+
if (requestedVar in envGraph.configSchema) {
164+
throw new CliExitError(`"${requestedVar}" is not marked as sensitive`, {
165+
suggestion: 'Use `varlock printenv` for non-sensitive values.',
166+
});
167+
}
168+
throw new CliExitError(`Variable "${requestedVar}" not found in schema`);
169+
}
170+
171+
if (copyMode) {
172+
await copyToClipboard(String(item.resolvedValue ?? ''));
173+
console.log(`\n Copied ${ansis.cyan(item.key)} to clipboard.`);
174+
console.log(ansis.gray(` Clipboard will be cleared in ${CLIPBOARD_CLEAR_DELAY_MS / 1000}s.\n`));
175+
setTimeout(async () => {
176+
await clearClipboard();
177+
console.log(ansis.gray(' Clipboard cleared.'));
178+
gracefulExit();
179+
}, CLIPBOARD_CLEAR_DELAY_MS);
180+
return;
181+
}
182+
183+
displayRevealedValue(item);
184+
await waitForKeypress();
185+
exitAltScreen();
186+
return;
187+
}
188+
189+
// Interactive picker loop
190+
while (true) {
191+
const selected = await select<string>({
192+
message: `Select a variable to reveal ${ansis.gray('(use arrows, enter to select)')}`,
193+
options: sensitiveItems.map((item) => ({
194+
value: item.key,
195+
label: item.key,
196+
hint: redactString(String(item.resolvedValue ?? '')) ?? undefined,
197+
})),
198+
});
199+
200+
if (isCancel(selected)) return gracefulExit();
201+
202+
const item = sensitiveItems.find((i) => i.key === selected)!;
203+
204+
if (copyMode) {
205+
await copyToClipboard(String(item.resolvedValue ?? ''));
206+
console.log(`\n Copied ${ansis.cyan(item.key)} to clipboard.`);
207+
console.log(ansis.gray(` Clipboard will be cleared in ${CLIPBOARD_CLEAR_DELAY_MS / 1000}s.\n`));
208+
setTimeout(async () => {
209+
await clearClipboard();
210+
console.log(ansis.gray(' Clipboard cleared.'));
211+
gracefulExit();
212+
}, CLIPBOARD_CLEAR_DELAY_MS);
213+
return;
214+
}
215+
216+
displayRevealedValue(item);
217+
await waitForKeypress();
218+
exitAltScreen();
219+
220+
// Loop back to the picker to reveal another value
221+
}
222+
};

0 commit comments

Comments
 (0)