vault + teams: filter secrets by category, export to CSV, simpler help

Every secret now has a category derived from its name (db, social, server,
api, cloud, finance, crypto, ai, email, messaging, storage, dns, analytics,
devtools, auth, config, other). One rule table in @logicsrc/opencreds serves
both vaults; services win over generic words, so STRIPE_WEBHOOK_SECRET is
finance, not auth. Checked against the 1,208 distinct key names in the
profullstack team: 74 fall to "other".

Team vaults (where the shared .env secrets live):
- teams categories [team]    the filter words, with per-category counts
- teams secrets <team> [project] [env] --category/-c --search/-s
                             names + categories, never decrypts; --format csv
- teams export  <team> [project] [env] --category -o file.csv [--yes]
                             decrypts into team,project,env,category,key,
                             value,updated_at (0600); skips vaults without a
                             grant and names them

Personal vault (OpenCreds):
- vault list --category, and the category column in list output
- vault export --format csv: one flat row per item, keeps key/account
  secrets that a Bitwarden CSV drops; --category on every export format

DX:
- examples in `logicsrc vault help` / -h / --help that start by saying which
  of the two vaults you want, plus examples on teams and each subcommand
- password prompts go to stderr, so eval "$(logicsrc vault unlock)" works
- hints name the command you actually ran (logicsrc vault init, not
  opencreds init)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-09-24 03:57:32 +00:00
parent 156c9164a9
commit a0f8f2c125
11 changed files with 846 additions and 19 deletions

View file

@ -18,13 +18,46 @@ import { registerCredsCommands } from "@logicsrc/opencreds/commands";
* account — encrypted end to end and portable as one file rather than a
* plaintext CSV. They meet at the `key` item: a synced .env entry, stored.
*/
export const VAULT_GUIDE = `
There are two vaults. Pick the one you need:
Team secrets .env keys shared with your team (DATABASE_URL, STRIPE_SECRET_KEY, …)
-> logicsrc teams … (this is where the company secrets are)
Personal vault your own logins, cards, SSH keys, notes
-> logicsrc vault …
Team secrets, the everyday commands:
logicsrc login once per machine
logicsrc teams list the teams you are in
logicsrc teams secrets <team> every secret name + its category
logicsrc teams secrets <team> --category db only database secrets
logicsrc teams secrets <team> -s stripe names containing "stripe"
logicsrc teams export <team> --category db -o db.csv decrypt them into a CSV
logicsrc teams pull <team> <project> <env> write one vault into ./.env
logicsrc teams categories db, social, server, api, cloud, …
Personal vault:
logicsrc vault init create it (once)
eval "$(logicsrc vault unlock)" unlock for this shell
logicsrc vault add login --name GitHub --username me --password -
logicsrc vault list --category social never shows values
logicsrc vault get GitHub --field login.password --reveal
logicsrc vault export --format csv --category db --out db.csv --yes
logicsrc vault import bitwarden.csv
Help for any command: logicsrc vault <command> --help (or: logicsrc vault help <command>)
`;
export function registerOpenCredsCommands(program: Command): void {
const vault = program
.command("vault")
.description(
"OpenCreds: an end-to-end-encrypted vault for logins, cards, identities, notes, keys " +
"and accounts, portable as one file. Also available as the standalone `opencreds` command.",
);
"Your personal encrypted vault (logins, cards, keys, notes). " +
"Team .env secrets are under `logicsrc teams` — examples below.",
)
// Most people typing `logicsrc vault` want the TEAM secrets, which live
// under `teams`. Say so first, with commands they can paste.
.addHelpText("after", VAULT_GUIDE);
registerCredsCommands(vault);
}

View file

@ -20,3 +20,18 @@ export function print(data: unknown, format: OutputFormat) {
console.table(data);
}
/**
* A plain, aligned table: no index column, no quotes, no colour. Easier to read
* than console.table and pipes cleanly into grep, sort and awk.
*/
export function printColumns(rows: Array<Record<string, unknown>>): void {
if (rows.length === 0) return;
const headers = Object.keys(rows[0]!);
const cells = rows.map((row) => headers.map((h) => String(row[h] ?? "")));
const widths = headers.map((h, i) => Math.max(h.length, ...cells.map((c) => c[i]!.length)));
const line = (values: string[]) =>
values.map((v, i) => (i === values.length - 1 ? v : v.padEnd(widths[i]!))).join(" ").trimEnd();
console.log(line(headers.map((h) => h.toUpperCase())));
for (const c of cells) console.log(line(c));
}

View file

@ -20,6 +20,9 @@ import {
teamsAcceptAction,
teamsMembersAction,
teamsVaultsAction,
teamsCategoriesAction,
teamsSecretsAction,
teamsExportAction,
teamsGrantAction,
teamsTuiAction,
teamsPushAction,
@ -716,7 +719,25 @@ credentials
print(credentialEngine().exportCredentialAudit(options.run), options.format as OutputFormat);
});
const teams = program.command("teams").description("Share credentials with teammates by email — end-to-end encrypted team vaults.");
const teams = program
.command("teams")
.description("Share credentials with teammates by email — end-to-end encrypted team vaults.")
.addHelpText(
"after",
`
Quick start (<team> is e.g. profullstack; see yours with "logicsrc teams list"):
logicsrc login once per machine
logicsrc teams vaults <team> the vaults: <project>--<env>
logicsrc teams secrets <team> every secret name + its category
logicsrc teams secrets <team> --category db filter: db, social, server, api, …
logicsrc teams export <team> --category db -o db.csv decrypt into a CSV
logicsrc teams pull <team> <project> <env> vault -> ./.env
logicsrc teams push <team> <project> <env> ./.env -> vault
logicsrc teams grant <team> <project> <env> dev@example.com
Categories: logicsrc teams categories. Help for one command: logicsrc teams <command> --help
`
);
teams
.command("create")
@ -762,6 +783,83 @@ teams
.description("List a team's credential vaults.")
.action((slug, options) => teamsVaultsAction(slug, options.format as OutputFormat));
// `--category db --category api` and `--category db,api` both work.
const collectCategory = (value: string, previous: string[] = []) => [...previous, value];
teams
.command("categories")
.argument("[slug]", "Team slug; when given, counts that team's secrets per category")
.option("--format <format>", "table, json, or markdown", "table")
.description("List the secret categories you can filter by (db, social, server, api, …).")
.addHelpText(
"after",
`
Examples:
logicsrc teams categories the category words and what each one catches
logicsrc teams categories profullstack how many secrets the team has in each
The category is worked out from the secret's NAME, e.g. DATABASE_URL is db and
TWITTER_API_KEY is social. Use a category with "teams secrets" and "teams export".
`
)
.action((slug, options) => teamsCategoriesAction(slug, { format: options.format as OutputFormat }));
teams
.command("secrets")
.alias("ls")
.argument("<slug>", "Team slug")
.argument("[project]", "Only this project (omit for every vault in the team)")
.argument("[env]", "Only this environment (prod, staging, …)")
.option("-c, --category <names>", "db, social, server, api, … (comma-separated or repeated)", collectCategory)
.option("-s, --search <text>", "only keys whose name contains this")
.option("--format <format>", "table, json, markdown, or csv", "table")
.description("List secret names and their category. Never decrypts or shows a value.")
.addHelpText(
"after",
`
Examples:
logicsrc teams secrets profullstack every secret name in the team
logicsrc teams secrets profullstack --category db database secrets only
logicsrc teams secrets profullstack -c social,api two categories at once
logicsrc teams secrets profullstack coinpayportal prod one vault
logicsrc teams secrets profullstack -s stripe names containing "stripe"
logicsrc teams secrets profullstack --format csv > names.csv
Categories: logicsrc teams categories
`
)
.action((slug, project, env, options) =>
teamsSecretsAction(slug, { project, env, category: options.category, search: options.search }, { format: options.format })
);
teams
.command("export")
.argument("<slug>", "Team slug")
.argument("[project]", "Only this project (omit for every vault in the team)")
.argument("[env]", "Only this environment (prod, staging, …)")
.option("-o, --out <file>", "CSV file to write, or - for stdout", "secrets.csv")
.option("-c, --category <names>", "db, social, server, api, … (comma-separated or repeated)", collectCategory)
.option("-s, --search <text>", "only keys whose name contains this")
.option("-y, --yes", "skip the are-you-sure prompt (needed when not in a terminal)")
.description("Decrypt secrets into a CSV: team,project,env,category,key,value,updated_at.")
.addHelpText(
"after",
`
Examples:
logicsrc teams export profullstack everything you can decrypt -> secrets.csv
logicsrc teams export profullstack --category db -o db.csv
logicsrc teams export profullstack coinpayportal prod -o coinpay-prod.csv
logicsrc teams export profullstack -c social --out - --yes | less
The file holds every value in the clear (written 0600). Delete it when done:
shred -u secrets.csv
Vaults you have no access to are skipped and listed at the end.
`
)
.action((slug, project, env, options) =>
teamsExportAction(slug, { project, env, category: options.category, search: options.search }, { out: options.out, yes: options.yes })
);
teams
.command("tui")
.alias("ui")

View file

@ -3,6 +3,8 @@ import { createHash, randomBytes } from "node:crypto";
import { hostname } from "node:os";
import { spawn } from "node:child_process";
import { createInterface } from "node:readline/promises";
import { chmodSync, writeFileSync } from "node:fs";
import { OTHER_CATEGORY, SECRET_CATEGORIES, categorizeSecret, csvLine, parseCategories } from "@logicsrc/opencreds";
import {
TeamClient,
TeamApiError,
@ -16,9 +18,11 @@ import {
identityPath,
unwrapVaultKey,
wrapVaultKey,
type CredentialEndpoint
decryptValue,
type CredentialEndpoint,
type RemoteVault
} from "@logicsrc/plugin-credential-sharing";
import { print, type OutputFormat } from "./format.js";
import { print, printColumns, type OutputFormat } from "./format.js";
import { linkedDirectory, requireSecretsLink, writeSecretsLink } from "./secrets-link.js";
/**
@ -516,3 +520,213 @@ export async function teamsTuiAction(options: { theme?: string } = {}): Promise<
const { runVaultTui } = await import("./vault-tui-run.js");
await runVaultTui({ client, identity: identity.email, theme: options.theme });
}
// ------------------------------------------------------------ categories ---
//
// `teams secrets` and `teams export` answer "show me every database password"
// or "give me all the social keys as a spreadsheet". The category comes from
// the key NAME (see @logicsrc/opencreds categories.ts), so listing never has to
// decrypt anything and a list and an export of the same filter always agree.
export interface SecretFilter {
project?: string;
env?: string;
category?: string | string[];
search?: string;
}
interface SecretRow {
team: string;
project: string;
env: string;
vault: string;
category: string;
key: string;
updatedAt: string;
}
/** Map with at most `limit` promises in flight; a team can hold hundreds of vaults. */
async function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out = new Array<R>(items.length);
let next = 0;
const worker = async (): Promise<void> => {
while (next < items.length) {
const index = next++;
out[index] = await fn(items[index]!);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return out;
}
/** The vaults a filter addresses: all of them, one project's, or one project/env. */
function selectVaults(vaults: RemoteVault[], filter: SecretFilter): Array<RemoteVault & { project: string; env: string }> {
return vaults
.map((v) => {
const parts = splitVaultName(v.name);
return { ...v, project: parts?.project ?? v.name, env: parts?.env ?? "" };
})
.filter((v) => (filter.project ? v.project === filter.project : true))
.filter((v) => (filter.env ? v.env === filter.env : true))
.sort((a, b) => a.name.localeCompare(b.name));
}
function matcher(filter: SecretFilter): (row: { key: string; category: string }) => boolean {
const categories = parseCategories(filter.category);
const needle = filter.search?.toLowerCase();
return (row) =>
(!categories || categories.has(row.category)) && (!needle || row.key.toLowerCase().includes(needle));
}
/** Every secret NAME the filter matches, with its category. Decrypts nothing. */
async function collectSecretRows(client: TeamClient, slug: string, filter: SecretFilter): Promise<SecretRow[]> {
const keep = matcher(filter);
const { vaults } = await client.listVaults(slug);
const selected = selectVaults(vaults, filter);
if (selected.length === 0) throw new Error(noVaultsMessage(slug, filter));
const perVault = await mapLimit(selected, 8, async (v) => {
const { secrets } = await client.listSecrets(v.id);
return secrets.map((s) => ({
team: slug, project: v.project, env: v.env, vault: v.name,
category: categorizeSecret(s.name), key: s.name, updatedAt: s.updatedAt
}));
});
return perVault.flat().filter(keep);
}
function noVaultsMessage(slug: string, filter: SecretFilter): string {
const where = [filter.project, filter.env].filter(Boolean).join("/");
return where
? `No vault matches ${slug}/${where}. See what exists: logicsrc teams vaults ${slug}`
: `Team "${slug}" has no vaults yet. Push one: logicsrc teams push ${slug} <project> <env>`;
}
function namesCsv(rows: SecretRow[]): string {
const lines = [csvLine(["team", "project", "env", "category", "key", "updated_at"])];
for (const r of rows) lines.push(csvLine([r.team, r.project, r.env, r.category, r.key, r.updatedAt]));
return `${lines.join("\n")}\n`;
}
/** `logicsrc teams categories [team]` — the filter words, with counts when a team is given. */
export async function teamsCategoriesAction(slug: string | undefined, options: { format: OutputFormat }): Promise<void> {
let counts: Map<string, number> | undefined;
if (slug) {
const { client } = authedClient();
counts = new Map();
for (const row of await collectSecretRows(client, slug, {})) counts.set(row.category, (counts.get(row.category) ?? 0) + 1);
}
const rows = [...SECRET_CATEGORIES, OTHER_CATEGORY].map((c) => ({
category: c.id,
...(counts ? { secrets: counts.get(c.id) ?? 0 } : {}),
description: c.description,
aliases: c.aliases.join(", "),
examples: c.examples.join(", ")
}));
if (options.format === "table") {
printColumns(rows.map(({ examples: _examples, ...row }) => row));
console.error("\nFilter with: logicsrc teams secrets <team> --category db (or: teams export <team> --category db)");
return;
}
print(rows, options.format);
}
/** `logicsrc teams secrets <team> [project] [env]` — names and categories, never values. */
export async function teamsSecretsAction(
slug: string,
filter: SecretFilter,
options: { format: OutputFormat | "csv" }
): Promise<void> {
const { client } = authedClient();
const rows = await collectSecretRows(client, slug, filter);
if (options.format === "csv") {
process.stdout.write(namesCsv(rows));
return;
}
if (rows.length === 0) {
console.error("No secrets match. Categories: logicsrc teams categories");
return;
}
const vaults = new Set(rows.map((r) => r.vault)).size;
console.error(`${rows.length} secret(s) in ${vaults} vault(s). Values stay encrypted; export them with: logicsrc teams export ${slug} …`);
const brief = rows.map((r) => ({ project: r.project, env: r.env, category: r.category, key: r.key }));
if (options.format === "table") printColumns(brief);
else print(options.format === "json" ? rows : brief, options.format);
}
async function confirmPlaintext(message: string): Promise<boolean> {
if (!process.stdin.isTTY || !process.stderr.isTTY) return false;
const prompt = createInterface({ input: process.stdin, output: process.stderr });
try {
return /^y(es)?$/i.test((await prompt.question(`${message} [y/N] `)).trim());
} finally {
prompt.close();
}
}
/**
* `logicsrc teams export <team> [project] [env] --out creds.csv`
*
* Decrypts on this machine and writes one CSV row per secret:
* team,project,env,category,key,value,updated_at. Vaults you hold no grant for
* are skipped and named, rather than failing the whole export.
*/
export async function teamsExportAction(
slug: string,
filter: SecretFilter,
options: { out: string; yes?: boolean }
): Promise<void> {
const { client, identity } = authedClient();
const keep = matcher(filter);
const { vaults } = await client.listVaults(slug);
const selected = selectVaults(vaults, filter);
if (selected.length === 0) throw new Error(noVaultsMessage(slug, filter));
const toStdout = options.out === "-";
const target = toStdout ? "stdout" : options.out;
if (!options.yes) {
console.error(`About to write decrypted secrets from ${selected.length} vault(s) to ${target} in the clear.`);
console.error("Anyone who can read that file can use every secret in it.");
if (!(await confirmPlaintext("Continue?"))) {
throw new Error("Refused: exporting plaintext secrets needs confirmation. Re-run with --yes to skip the prompt.");
}
}
const skipped: string[] = [];
const perVault = await mapLimit(selected, 6, async (v) => {
if (!v.hasAccess) {
skipped.push(v.name);
return [];
}
const { secrets } = await client.listSecrets(v.id);
const wanted = secrets
.map((s) => ({ secret: s, category: categorizeSecret(s.name), key: s.name }))
.filter(keep);
if (wanted.length === 0) return [];
let dek: string;
try {
dek = await unwrapVaultKey((await client.getMyGrant(v.id)).wrappedDek, identity.keys);
} catch {
skipped.push(v.name);
return [];
}
return Promise.all(
wanted.map(async ({ secret, category }) =>
csvLine([slug, v.project, v.env, category, secret.name,
await decryptValue({ nonce: secret.nonce, ciphertext: secret.ciphertext }, dek), secret.updatedAt])
)
);
});
const lines = perVault.flat();
const csv = `${[csvLine(["team", "project", "env", "category", "key", "value", "updated_at"]), ...lines].join("\n")}\n`;
if (toStdout) {
process.stdout.write(csv);
} else {
writeFileSync(options.out, csv, { encoding: "utf8", mode: 0o600 });
try { chmodSync(options.out, 0o600); } catch { /* no modes on this platform */ }
}
console.error(`Exported ${lines.length} secret(s) to ${target}.${toStdout ? "" : " Delete it when you are done: shred -u " + options.out}`);
if (skipped.length) {
console.error(`Skipped ${skipped.length} vault(s) you cannot decrypt: ${skipped.sort().join(", ")}`);
}
}