diff --git a/README.md b/README.md index b6fec48..e608f82 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,23 @@ keep push myapp/production --file .env.production keep pull myapp/production > .env ``` +## Templates + +`keep push` also saves a redacted copy of the source `.env` alongside +the secrets — same keys, comments, and blank lines, values stripped. +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 +keeps it without keeping the secret itself around unencrypted anywhere. + +```bash +keep pull myapp/production --template > .env.example +``` + +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 +of silently returning nothing. No migration needed; push again and the +template gets backfilled from whatever `.env` you push next. + ## Granting, revoking, rotating ```bash diff --git a/src/cli/commands/vault.ts b/src/cli/commands/vault.ts index 8a0ab11..fa3c4b0 100644 --- a/src/cli/commands/vault.ts +++ b/src/cli/commands/vault.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import { requireIdentityAndRecipientId } from '../identityStore.js'; import { signedFetch, adminFetch, expectOk } from '../signedFetch.js'; -import { parseEnv, serializeEnv } from '../envFormat.js'; +import { parseEnv, serializeEnv, buildEnvTemplate } from '../envFormat.js'; import { ready, Identity, @@ -16,8 +16,13 @@ export async function vaultPush(vaultKey: string, file: string, purge: boolean): const { identity, recipientId } = await requireIdentityAndRecipientId(); if (!fs.existsSync(file)) throw new Error(`file not found: ${file}`); - const values = parseEnv(fs.readFileSync(file, 'utf8')); - const payload = enc.fromUtf8(JSON.stringify(values)); + const raw = fs.readFileSync(file, 'utf8'); + const values = parseEnv(raw); + const template = buildEnvTemplate(raw); + // __keepEnvelope marks the new wrapped shape so vaultPull can tell it + // apart from an old vault pushed before templates existed, which is + // just the flat { KEY: value } object with no wrapper at all. + const payload = enc.fromUtf8(JSON.stringify({ __keepEnvelope: true, values, template })); const existsRes = await expectOk(await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/exists`)); @@ -55,7 +60,7 @@ export async function vaultPush(vaultKey: string, file: string, purge: boolean): console.log(`pushed '${vaultKey}' — ${Object.keys(values).length} key${Object.keys(values).length === 1 ? '' : 's'}, wrapped for ${wrapFor.size} recipient${wrapFor.size === 1 ? '' : 's'}${purge ? ' (purged previous version)' : ''}.`); } -export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFile: string | undefined, previous: boolean): Promise { +export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFile: string | undefined, previous: boolean, wantTemplate: boolean): Promise { await ready(); const { identity, recipientId } = await requireIdentityAndRecipientId(); @@ -66,7 +71,23 @@ export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFil if (!symmetricKey) throw new Error('failed to unwrap this vault\'s key — wrong identity, or the grant is stale'); const plaintext = secretboxDecrypt(symmetricKey, enc.fromBase64(data.nonce), enc.fromBase64(data.ciphertext)); - const values = JSON.parse(enc.toUtf8(plaintext)) as Record; + const parsed = JSON.parse(enc.toUtf8(plaintext)) as Record; + + // Vaults pushed before templates existed are just the flat { KEY: value } + // object with no wrapper — __keepEnvelope tells the two shapes apart. + const values = (parsed.__keepEnvelope ? parsed.values : parsed) as Record; + const template = parsed.__keepEnvelope ? (parsed.template as string | undefined) : undefined; + + if (wantTemplate) { + if (template === undefined) throw new Error(`vault '${vaultKey}' has no saved template (pushed before template support, or the source file had no comments to preserve)`); + if (outFile) { + fs.writeFileSync(outFile, template); + console.error(`wrote template to ${outFile}${previous ? ' (previous version)' : ''}`); + } else { + process.stdout.write(template); + } + return; + } const out = format === 'json' ? JSON.stringify(values, null, 2) : serializeEnv(values); if (outFile) { diff --git a/src/cli/envFormat.ts b/src/cli/envFormat.ts index b15642c..df72035 100644 --- a/src/cli/envFormat.ts +++ b/src/cli/envFormat.ts @@ -20,3 +20,21 @@ export function serializeEnv(values: Record): string { .map(([k, v]) => `${k}=${/[\s#"']/.test(v) ? JSON.stringify(v) : v}`) .join('\n') + '\n'; } + +// Redacts values while keeping comments, blank lines, and key order intact +// — the whole point is to preserve the "why"/"where to get this" context +// that plain parseEnv() throws away, without keeping the actual secret +// around in the template. +export function buildEnvTemplate(text: string): string { + return text + .split('\n') + .map(rawLine => { + const line = rawLine.trimEnd(); + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return line; + const eq = line.indexOf('='); + if (eq === -1) return line; + return `${line.slice(0, eq)}=`; + }) + .join('\n'); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index e686ffa..6d8bf69 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -31,7 +31,7 @@ async function main(): Promise { if (cmd === 'overview') return await adminOverview(); if (cmd === 'push') return await vaultPush(sub, flag(rest, 'file', '.env')!, boolFlag(rest, 'purge')); - if (cmd === 'pull') return await vaultPull(sub, (flag(rest, 'format', 'env') as 'env' | 'json'), flag(rest, 'out'), boolFlag(rest, 'previous')); + if (cmd === 'pull') return await vaultPull(sub, (flag(rest, 'format', 'env') as 'env' | 'json'), flag(rest, 'out'), boolFlag(rest, 'previous'), boolFlag(rest, 'template')); if (cmd === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only')); if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]); if (cmd === 'log') return await vaultLog(sub); @@ -54,7 +54,8 @@ function printUsage(): void { keep identity set-id keep push [--file .env] [--purge] (--purge: don't retain the outgoing version as rollback) - keep pull [--format env|json] [--out ] [--previous] + keep pull [--format env|json] [--out ] [--previous] [--template] + (--template: comments/structure with values redacted, not the secrets) keep grant [--read-only] keep revoke (admin — needs KEEP_ADMIN_PASSWORD) keep log