keep pull: fill saved template with real values by default
All checks were successful
Docker / build-and-push (push) Successful in 1m54s

A bare key/value dump throws away the comments/structure a template
preserves; now a plain pull reconstitutes the real .env from the
template when the vault has one, falling back to the flat dump
otherwise. --template still returns the redacted structure alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-16 16:52:02 +02:00
parent 04b48e05e4
commit a640bc1faf
4 changed files with 31 additions and 6 deletions

View File

@@ -124,14 +124,19 @@ Comments explaining *where a value comes from* or *why it exists* are
exactly the kind of context a bare key/value pair throws away, so this exactly the kind of context a bare key/value pair throws away, so this
keeps it without keeping the secret itself around unencrypted anywhere. keeps it without keeping the secret itself around unencrypted anywhere.
A plain `keep pull` uses that template by default: if the vault has one,
you get real values filled back into it, comments and all — not just a
bare key/value dump. Ask for the redacted structure itself with:
```bash ```bash
keep pull myapp/production --template > .env.example keep pull myapp/production --template > .env.example
``` ```
Vaults pushed before this existed just don't have a saved template — Vaults pushed before this existed just don't have a saved template —
`keep pull --template` on one of those fails with a clear error instead `keep pull` on one of those falls back to a plain key/value dump, and
of silently returning nothing. No migration needed; push again and the `keep pull --template` fails with a clear error instead of silently
template gets backfilled from whatever `.env` you push next. returning nothing. No migration needed; push again and the template
gets backfilled from whatever `.env` you push next.
## Granting, revoking, rotating ## Granting, revoking, rotating

View File

@@ -1,7 +1,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import { requireIdentityAndRecipientId } from '../identityStore.js'; import { requireIdentityAndRecipientId } from '../identityStore.js';
import { signedFetch, adminFetch, expectOk } from '../signedFetch.js'; import { signedFetch, adminFetch, expectOk } from '../signedFetch.js';
import { parseEnv, serializeEnv, buildEnvTemplate } from '../envFormat.js'; import { parseEnv, serializeEnv, buildEnvTemplate, fillEnvTemplate } from '../envFormat.js';
import { import {
ready, ready,
Identity, Identity,
@@ -89,7 +89,9 @@ export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFil
return; return;
} }
const out = format === 'json' ? JSON.stringify(values, null, 2) : serializeEnv(values); const out = format === 'json'
? JSON.stringify(values, null, 2)
: template !== undefined ? fillEnvTemplate(template, values) : serializeEnv(values);
if (outFile) { if (outFile) {
fs.writeFileSync(outFile, out); fs.writeFileSync(outFile, out);
console.error(`wrote ${Object.keys(values).length} key(s) to ${outFile}${previous ? ' (previous version)' : ''}`); console.error(`wrote ${Object.keys(values).length} key(s) to ${outFile}${previous ? ' (previous version)' : ''}`);

View File

@@ -25,6 +25,23 @@ export function serializeEnv(values: Record<string, string>): string {
// — the whole point is to preserve the "why"/"where to get this" context // — the whole point is to preserve the "why"/"where to get this" context
// that plain parseEnv() throws away, without keeping the actual secret // that plain parseEnv() throws away, without keeping the actual secret
// around in the template. // around in the template.
// Inverse of buildEnvTemplate: substitutes real values back into a
// template's `KEY=` lines while leaving comments/blank lines/order intact.
export function fillEnvTemplate(template: string, values: Record<string, string>): string {
return template
.split('\n')
.map(line => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
const eq = line.indexOf('=');
if (eq === -1) return line;
const key = line.slice(0, eq);
const value = values[key.trim()] ?? '';
return `${key}=${/[\s#"']/.test(value) ? JSON.stringify(value) : value}`;
})
.join('\n');
}
export function buildEnvTemplate(text: string): string { export function buildEnvTemplate(text: string): string {
return text return text
.split('\n') .split('\n')

View File

@@ -55,7 +55,8 @@ function printUsage(): void {
keep push <vault> [--file .env] [--purge] (--purge: don't retain the outgoing version as rollback) keep push <vault> [--file .env] [--purge] (--purge: don't retain the outgoing version as rollback)
keep pull <vault> [--format env|json] [--out <path>] [--previous] [--template] keep pull <vault> [--format env|json] [--out <path>] [--previous] [--template]
(--template: comments/structure with values redacted, not the secrets) (fills the saved template with real values by default, if one exists;
--template: comments/structure with values redacted, not the secrets)
keep grant <vault> <recipient-id> [--read-only] keep grant <vault> <recipient-id> [--read-only]
keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD) keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD)
keep log <vault> keep log <vault>