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(", ")}`);
}
}

View file

@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
SECRET_CATEGORIES,
SECRET_CATEGORY_IDS,
categorizeItem,
categorizeSecret,
csvLine,
parseCategories,
toSimpleCsv,
} from "./categories.js";
import { createItem } from "./items.js";
describe("categorizeSecret", () => {
it("puts every documented example in its own category", () => {
for (const category of SECRET_CATEGORIES) {
for (const example of category.examples) {
expect([example, categorizeSecret(example)]).toEqual([example, category.id]);
}
}
});
it.each([
["DATABASE_URL", "db"],
["SUPABASE_SERVICE_ROLE_KEY", "db"],
["SUPABAsE_URL", "db"],
["TWITTER_API_KEY", "social"],
["X_CLIENT_SECRET", "social"],
["SSH_PORT", "server"],
["SEED1_SUDO_PASSWORD", "server"],
["TMDB_API_KEY", "api"],
// a service beats the generic word: rotate Stripe, get its webhook secret too
["STRIPE_WEBHOOK_SECRET", "finance"],
["COINPAYPORTAL_API_KEY", "finance"],
["SMTP_HOST", "email"],
["DB_HOST", "db"],
["R2_SECRET_ACCESS_KEY", "storage"],
["SUPABASE_S3_SECRET_KEY", "storage"],
["SYSTEM_MNEMONIC_BTC", "crypto"],
["JWT_SECRET", "auth"],
["NODE_ENV", "config"],
["NEXT_PUBLIC_APP_URL", "config"],
["PLANETSCALE_DATABASE_URL", "db"],
["SOMETHING_ELSE", "other"],
])("%s is %s", (name, category) => {
expect(categorizeSecret(name)).toBe(category);
});
it("reads item names and hosts the same way as env names", () => {
expect(categorizeSecret("My Postgres")).toBe("db");
expect(categorizeSecret("api.stripe.com")).toBe("finance");
});
});
describe("categorizeItem", () => {
it("uses the item type where it is unambiguous", () => {
expect(categorizeItem(createItem("card", { name: "Visa" }))).toBe("finance");
expect(categorizeItem(createItem("key", { name: "laptop", key: { keyType: "ssh" } }))).toBe("server");
});
it("classifies a login by its URL when the name says nothing", () => {
const item = createItem("login", { name: "work", login: { uris: [{ uri: "https://www.reddit.com/login" }] } });
expect(categorizeItem(item)).toBe("social");
});
it("classifies an env key by its name", () => {
expect(categorizeItem(createItem("key", { name: "REDIS_URL", key: { keyType: "env", value: "redis://x" } }))).toBe("db");
});
});
describe("parseCategories", () => {
it("accepts commas, repeats and aliases", () => {
expect(parseCategories(["db,social", "payments"])).toEqual(new Set(["db", "social", "finance"]));
expect(parseCategories(" DB ")).toEqual(new Set(["db"]));
});
it("means no filter when nothing is given", () => {
expect(parseCategories(undefined)).toBeUndefined();
expect(parseCategories([])).toBeUndefined();
});
it("names the valid words when one is wrong", () => {
expect(() => parseCategories("databse")).toThrow(/Unknown category "databse".*db/);
});
it("lists other last so every secret has a category to filter on", () => {
expect(SECRET_CATEGORY_IDS.at(-1)).toBe("other");
});
});
describe("csv", () => {
it("quotes only what needs quoting, including multi-line keys", () => {
expect(csvLine(["a", 'b"c', "d,e", "-----BEGIN\nKEY-----", undefined])).toBe('a,"b""c","d,e","-----BEGIN\nKEY-----",');
});
it("keeps key and account secrets, which a Bitwarden CSV drops", () => {
const items = [
createItem("key", { name: "DATABASE_URL", key: { keyType: "env", value: "postgres://u:p@h/db" } }),
createItem("account", { name: "Mastodon", account: { provider: "mastodon", handle: "@me", accessToken: "tok" } }),
];
const lines = toSimpleCsv(items, []).trim().split("\n");
expect(lines[0]).toBe("folder,category,type,name,username,password,url,value,totp,notes");
expect(lines[1]).toBe(",db,key,DATABASE_URL,,,,postgres://u:p@h/db,,");
expect(lines[2]).toBe(",social,account,Mastodon,@me,,mastodon,tok,,");
});
});

View file

@ -0,0 +1,224 @@
/**
* Secret categories — "is this a database password, a social login, a server
* key or an API token?" — derived from the NAME alone.
*
* Names only, on purpose: `logicsrc teams secrets` lists a vault without
* decrypting it, and `teams export` has the values in hand. If a value could
* move a secret between categories, the same key would land in one bucket when
* listed and another when exported, and a filter that disagrees with itself is
* worse than no filter.
*
* The rules are an ordered list and the first match wins, with specific
* services before generic words: STRIPE_WEBHOOK_SECRET is `finance` (you want
* it when rotating Stripe), not `auth` (every webhook secret), and SMTP_HOST is
* `email`, not `server`. Each pattern runs against the name upper-cased with
* every non-alphanumeric run turned into "_" and padded with "_" at both ends,
* so `_OPENAI_` is a whole token and `_COINPAY` is a token prefix
* (COINPAYPORTAL_API_KEY). The same normaliser turns an OpenCreds item name or
* login URL ("api.stripe.com") into tokens, so one table serves both vaults.
*/
import type { Item } from "./types.js";
export interface SecretCategory {
/** The name typed on the command line: `--category db`. */
id: string;
/** One line for `logicsrc teams categories`. */
description: string;
/** Other words people reach for; `--category payments` means `finance`. */
aliases: readonly string[];
/** A few names this category catches, shown as examples. */
examples: readonly string[];
patterns: readonly RegExp[];
}
const rule = (
id: string,
description: string,
aliases: string[],
examples: string[],
patterns: RegExp[],
): SecretCategory => Object.freeze({ id, description, aliases, examples, patterns });
export const SECRET_CATEGORIES: readonly SecretCategory[] = Object.freeze([
rule("crypto", "Wallets, seed phrases, chain RPCs, exchanges", ["wallet", "wallets", "web3", "blockchain", "exchange"],
["SYSTEM_MNEMONIC_BTC", "SOLANA_RPC_URL", "KRAKEN_API_KEY"], [
/_(MNEMONIC|SEED_PHRASE|WALLET|WALLETCONNECT|RPC|ALCHEMY|INFURA|HELIUS|QUICKNODE|TATUM|BLOCKFROST|BLOCKSTREAM|LNBITS|LIGHTNING|LN|EVM|SOLANA|SOL|ETH|ETHEREUM|BTC|BITCOIN|BCH|DOGE|XRP|XMR|MONERO|POLYGON|BNB|USDC|USDT|ZEROX|UNISWAP|PUMPFUN|CHANGENOW|KRAKEN|BINANCE|COINBASE|BITSTAMP|MOONPAY|SIDESHIFT|GEMINI_API_SECRET|BYBIT|OKX|KUCOIN|DEX|ARB)_/,
/_(ETHERSCAN|BSCSCAN|POLYGONSCAN|SOLSCAN|CRYPTO_?APIS)/,
]),
rule("ai", "LLM and model providers", ["llm", "ml", "model", "models"],
["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ELEVENLABS_API_KEY"], [
/_(OPENAI|ANTHROPIC|CLAUDE|GEMINI|GOOGLE_AI|GROQ|GROK|XAI|MISTRAL|COHERE|DEEPSEEK|PERPLEXITY|MOONSHOT|DASHSCOPE|QWEN|ZAI|OPENROUTER|TOGETHER|FIREWORKS|REPLICATE|HUGGINGFACE|HF|ELEVENLABS|STABILITY|OLLAMA|VOYAGE|AI)_/,
]),
rule("finance", "Payments, billing, banking and brokerage", ["payment", "payments", "billing", "bank", "banking", "stripe"],
["STRIPE_SECRET_KEY", "COINPAY_API_KEY", "PLAID_SECRET"], [
/_(STRIPE|PAYPAL|COINPAY|SQUARE|BRAINTREE|LEMONSQUEEZY|PADDLE|GUMROAD|CHARGEBEE|REVENUECAT|PLAID|SIMPLEFIN|BTCPAY|X402|ALPACA|APCA|FINNHUB|ROBINHOOD|SHOPIFY|MERCHANT)/,
/_(PAY|PAYMENT|PAYMENTS|PAYOUT|PAYOUTS|INVOICE)_/,
/_(PRICE|PLAN)_/,
]),
rule("email", "Sending and receiving mail", ["mail", "smtp"],
["RESEND_API_KEY", "SMTP_PASS", "MAILGUN_DOMAIN"], [
/_(RESEND|MAILGUN|SENDGRID|POSTMARK|SMTP|IMAP|POP3|SES|MAILCHIMP|BREVO|SENDINBLUE|MAILJET|SPARKPOST|MAIL|EMAIL|EMAILS)_/,
]),
rule("messaging", "SMS, voice, push, chat and realtime", ["sms", "phone", "push", "chat", "realtime"],
["TWILIO_AUTH_TOKEN", "VAPID_PRIVATE_KEY", "LIVEKIT_API_SECRET"], [
/_(TWILIO|TELNYX|VONAGE|NEXMO|PLIVO|SMS|PHONE|VAPID|ONESIGNAL|FCM|APNS|PUSHER|SLACK|DISCORD|TELEGRAM|WHATSAPP|LIVEKIT|TURN|JITSI|AGORA)_/,
]),
rule("social", "Social networks and publishing platforms", ["socials", "oauth-social"],
["X_CLIENT_SECRET", "REDDIT_CLIENT_ID", "META_APP_SECRET"], [
/_(TWITTER|X|FACEBOOK|FB|META|INSTAGRAM|THREADS|LINKEDIN|REDDIT|TIKTOK|YOUTUBE|MASTODON|BLUESKY|BSKY|PINTEREST|NOSTR|FARCASTER|DEVTO|HASHNODE|MEDIUM|POSTIZ|TUMBLR|SNAPCHAT|TWITCH|KICK|LEMMY|SOCIAL)_/,
]),
rule("storage", "Object storage, buckets and file/media hosts", ["s3", "bucket", "files", "media"],
["R2_SECRET_ACCESS_KEY", "SUPABASE_S3_SECRET_KEY", "CLOUDINARY_API_SECRET"], [
/_(S3|R2|B2|BUCKET|STORAGE|CLOUDINARY|UPLOADTHING|BACKBLAZE|MINIO|SPACES|DROPBOX|GCS)_/,
]),
rule("db", "Databases, caches and backends-as-a-service", ["database", "databases", "sql", "cache", "supabase"],
["DATABASE_URL", "SUPABASE_SERVICE_ROLE_KEY", "REDIS_URL"], [
/_(DATABASE|DB|POSTGRES|POSTGRESQL|PG|PGHOST|PGUSER|PGPASSWORD|PGDATABASE|MYSQL|MARIADB|MONGO|MONGODB|REDIS|VALKEY|UPSTASH|TURSO|LIBSQL|SQLITE|SQLITECLOUD|DBSTRING|SUPABASE|NEON|PLANETSCALE|COCKROACH|CLICKHOUSE|ELASTIC|ELASTICSEARCH|SURREAL|DYNAMODB|FIRESTORE|GOTRUE)_/,
]),
rule("dns", "Domains, DNS and TLS certificates", ["domain", "domains", "tls", "ssl", "certs"],
["PORKBUN_API_KEY", "PORKBUN_SECRET_API_KEY", "CLOUDFLARE_DNS_TOKEN"], [
/_(PORKBUN|NAMECHEAP|GODADDY|ROUTE53|DNSIMPLE|DNS|CERTBOT|ACME|LETSENCRYPT)_/,
]),
rule("analytics", "Analytics, error tracking and logging", ["monitoring", "observability", "logging", "tracking"],
["SENTRY_DSN", "POSTHOG_KEY", "GOOGLE_ANALYTICS_ID"], [
/_(SENTRY|DATADOG|POSTHOG|ANALYTICS|GA|PLAUSIBLE|UMAMI|MIXPANEL|AMPLITUDE|SEGMENT|LOGTAIL|BETTERSTACK|NEW_RELIC|NEWRELIC|GRAFANA|HONEYCOMB|DATAFAST|MAXMIND)_/,
]),
rule("devtools", "Source control, package registries, app stores, signing", ["dev", "git", "github", "ci", "registry"],
["GITHUB_TOKEN", "NPM_TOKEN", "GPG_PRIVATE_KEY"], [
/_(GITHUB|GH|GITLAB|BITBUCKET|GITEA|NPM|PYPI|DOCKER|GHCR|AUR|CHOCOLATEY|GPG|PGP|PKG|EXPO|EAS|APPLE|CHROME|FIREFOX|EDGE|CODECOV|LINEAR|JIRA|SH1PT)_/,
]),
rule("cloud", "Cloud and hosting platforms", ["hosting", "paas", "infra"],
["RAILWAY_API_TOKEN", "CLOUDFLARE_GLOBAL_API_TOKEN", "FIREBASE_PRIVATE_KEY"], [
/_(AWS|GCP|GCLOUD|GOOGLE_APPLICATION_CREDENTIALS|AZURE|CLOUDFLARE|CF|DIGITALOCEAN|HETZNER|VULTR|LINODE|RAILWAY|VERCEL|NETLIFY|FLY|RENDER|HEROKU|DOPPLER|FIREBASE)_/,
]),
rule("server", "Servers, SSH, hosts and proxies", ["ssh", "host", "hosts", "vps", "proxy"],
["SSH_PORT", "HOST_USER", "PROXY_PASSWORD"], [
/_(SSH|HOST|HOSTNAME|SERVER|VPS|SUDO|ROOT|DROPLET|SFTP|FTP|PROXY|SEED1)_/,
/_SEED\d+_/,
]),
rule("auth", "App auth: JWT/session/encryption keys, OAuth apps, webhook and signing secrets", ["jwt", "session", "oauth", "signing", "webhook", "encryption"],
["JWT_SECRET", "SESSION_SECRET", "GOOGLE_CLIENT_SECRET"], [
/_(JWT|JWKS|SESSION|AUTH|NEXTAUTH|OAUTH|CLIENT_SECRET|CLIENT_ID|ENCRYPTION|ENCRYPT|COOKIE|SIGNING|SIGN|HMAC|WEBHOOK|PEPPER|SALT|TOTP|OTP|PASSKEY|WEBAUTHN|CLERK|AUTH0|CAPTCHA|HCAPTCHA|RECAPTCHA|TURNSTILE|CRON|TICKET|VERIFY|CSRF|PASSWORD|PASS|USERNAME|LOGIN)_/,
/_SHARED_SECRET_/,
]),
rule("api", "Other third-party API keys and tokens", ["apis", "token", "tokens", "keys"],
["TMDB_API_KEY", "VALUESERP_API_KEY", "CAPSOLVER_API_KEY"], [
/_(API_KEY|API_TOKEN|API_SECRET|APIKEY|ACCESS_KEY|ACCESS_TOKEN|REFRESH_TOKEN|TOKEN|KEY|SECRET|PAT|PRIVATE_KEY|SECRET_KEY|LICENSE_KEY|KEY_ID)_/,
]),
rule("config", "Settings that are not secrets: URLs, ports, flags, limits, names", ["settings", "env", "public"],
["NODE_ENV", "PORT", "NEXT_PUBLIC_APP_URL"], [
/^_(NEXT_PUBLIC|PUBLIC|VITE|EXPO_PUBLIC|NODE|APP)_/,
/_(URL|URLS|URI|ORIGIN|ORIGINS|DOMAIN|NAME|ID|PORT|ENV|ENVIRONMENT|MODE|LEVEL|DEBUG|ENABLED|ENABLE|DISABLE|DISABLED|MS|SECONDS|MINUTES|TTL|LIMIT|MAX|MIN|PCT|BPS|USD|CENTS|PATH|DIR|FILE|DESCRIPTION|VERSION|REGION|CONFIG|PROJECT|MODEL|PROVIDER|FORMAT|TIMEOUT|HEADLESS|SIZE|AGENT|INTERVAL|FROM|TO|CONCURRENCY|WIDTH|HEIGHT|FPS|QUALITY|SCOPES|ROLES)_/,
]),
]);
/** Everything the rules did not recognise. Always a valid filter value. */
export const OTHER_CATEGORY: SecretCategory = rule("other", "Anything the rules did not recognise", ["misc", "unknown"], [], []);
export const SECRET_CATEGORY_IDS: readonly string[] = Object.freeze([...SECRET_CATEGORIES.map((c) => c.id), OTHER_CATEGORY.id]);
function tokens(text: string): string {
return `_${text.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "")}_`;
}
/** The category of one secret, from its name (an env var, an item name, a URL). */
export function categorizeSecret(...names: Array<string | undefined>): string {
const texts = names.filter((n): n is string => Boolean(n && n.trim())).map(tokens);
for (const category of SECRET_CATEGORIES) {
if (texts.some((text) => category.patterns.some((p) => p.test(text)))) return category.id;
}
return OTHER_CATEGORY.id;
}
/**
* The category of an OpenCreds item. The item type decides where it is
* unambiguous (a card is money, an ssh key is a server credential); otherwise
* the name, account provider and login hosts are classified like env names.
*/
export function categorizeItem(item: Item): string {
if (item.type === "card") return "finance";
switch (item.key?.keyType) {
case "ssh": return "server";
case "pgp": return "devtools";
case "certificate": return "dns";
}
const hosts = (item.login?.uris ?? []).map((u) => {
try {
return new URL(u.uri.includes("://") ? u.uri : `https://${u.uri}`).hostname.replace(/^www\./, "").replace(/\.[a-z]+$/, "");
} catch {
return u.uri;
}
});
const found = categorizeSecret(item.name, item.account?.provider, ...hosts);
if (found !== OTHER_CATEGORY.id) return found;
if (item.key?.keyType === "api") return "api";
if (item.key?.keyType === "symmetric") return "auth";
return found;
}
/**
* Parse `--category db,social --category api` into canonical ids, accepting
* aliases. Unknown words throw with the list of valid ones, so a typo fails
* loudly instead of silently matching nothing.
*/
export function parseCategories(input: string | readonly string[] | undefined): Set<string> | undefined {
if (input === undefined) return undefined;
const words = (Array.isArray(input) ? input : [input])
.flatMap((part) => String(part).split(","))
.map((word) => word.trim().toLowerCase())
.filter(Boolean);
if (words.length === 0) return undefined;
const all = [...SECRET_CATEGORIES, OTHER_CATEGORY];
const picked = new Set<string>();
for (const word of words) {
const match = all.find((c) => c.id === word || c.aliases.includes(word));
if (!match) {
throw new Error(`Unknown category "${word}". Choose from: ${SECRET_CATEGORY_IDS.join(", ")}.`);
}
picked.add(match.id);
}
return picked;
}
export const SIMPLE_CSV_COLUMNS = Object.freeze([
"folder", "category", "type", "name", "username", "password", "url", "value", "totp", "notes",
] as const);
/**
* One flat row per item, for a spreadsheet or a script: `username`/`password`
* for anything you log in with, and `value` for the one opaque secret of the
* rest — an env or API key, a private key, an access token, a card number.
* Unlike Bitwarden's CSV it keeps key and account items, which is where most
* developer secrets live. It is not an import format; export OpenCreds for that.
*/
export function toSimpleCsv(items: readonly Item[], folders: ReadonlyArray<{ id: string; name: string }>): string {
const folderName = new Map(folders.map((f) => [f.id, f.name]));
const lines = [csvLine(SIMPLE_CSV_COLUMNS)];
for (const item of items) {
const { login, key, account, card, identity } = item;
lines.push(csvLine([
item.folderId ? folderName.get(item.folderId) : "",
categorizeItem(item),
item.type,
item.name,
login?.username || account?.handle || account?.email || identity?.username || identity?.email || card?.cardholderName,
login?.password || key?.passphrase || card?.code,
login?.uris?.[0]?.uri || key?.path || account?.provider,
key?.value || key?.privateKey || account?.accessToken || card?.number,
login?.totp,
item.notes,
]));
}
return `${lines.join("\n")}\n`;
}
/** One RFC 4180 CSV line; quotes a cell only when it has to. */
export function csvLine(cells: ReadonlyArray<string | number | undefined | null>): string {
return cells
.map((cell) => {
const value = cell === undefined || cell === null ? "" : String(cell);
return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
})
.join(",");
}

View file

@ -24,6 +24,7 @@ import {
parseDatabase,
readHeader,
} from "./database.js";
import { categorizeItem, parseCategories, toSimpleCsv } from "./categories.js";
import { CSV_LOSSY_FIELDS, IMPORT_SOURCES, parseCsvImport, toBitwardenCsv } from "./importers.js";
import {
createItem,
@ -68,6 +69,12 @@ class CliError extends Error {
}
}
/**
* The command as the person typed it — `opencreds` standalone, `logicsrc vault`
* when mounted — so every hint and example is one they can paste as is.
*/
let cli = "opencreds";
function fail(message: string, code: number): never {
throw new CliError(message, code);
}
@ -94,7 +101,7 @@ function storeFor(command: Command) {
function requireMeta(store: ReturnType<typeof createVaultStore>) {
const meta = store.readMeta();
if (!meta) fail(`No vault at ${store.baseDir} — run \`opencreds init\` first`, EXIT.USAGE);
if (!meta) fail(`No vault at ${store.baseDir} — create one with \`${cli} init\``, EXIT.USAGE);
return meta;
}
@ -257,14 +264,27 @@ function applyTypeFlags(command: Command, type: ItemTypeName): Command {
return command;
}
function categoriesOrFail(input: string | undefined): Set<string> | undefined {
try {
return parseCategories(input);
} catch (error) {
return fail((error as Error).message, EXIT.USAGE);
}
}
function printItemLine(item: Item): string {
const type = item.type.padEnd(8);
const id = item.id.slice(0, 8);
return `${id} ${type} ${item.name}`;
const category = categorizeItem(item).padEnd(9);
return `${id} ${type} ${category} ${item.name}`;
}
/** Register every OpenCreds command onto `parent`. */
export function registerCredsCommands(parent: Command): void {
const names: string[] = [];
for (let c: Command | null = parent; c; c = c.parent) names.unshift(c.name());
cli = names.join(" ");
const examples = (lines: string) => `\nExamples:\n${lines.replace(/^\n/, "").replace(/\$CLI/g, cli)}\n`;
parent.option("--home <dir>", "vault directory (default $OPENCREDS_HOME)");
// ---------------------------------------------------------------- vault ---
@ -272,6 +292,9 @@ export function registerCredsCommands(parent: Command): void {
parent
.command("init")
.description("create a vault")
.addHelpText("after", examples(`
$CLI init create your vault (asks for a master password)
$CLI status is there a vault, is it unlocked, what is in it`))
.option("--namespace <name>", "domain-separation namespace", "opencreds")
.option("--iterations <n>", "PBKDF2 iterations", (v: string) => Number.parseInt(v, 10))
.option("--password-stdin", "read the master password from stdin instead of prompting twice")
@ -311,6 +334,9 @@ export function registerCredsCommands(parent: Command): void {
parent
.command("unlock")
.description("start a session")
.addHelpText("after", examples(`
eval "$($CLI unlock)" unlock for this shell only (nothing written to disk)
$CLI unlock --persist --timeout 30 unlock for scripts for 30 minutes; \`lock\` ends it`))
.option("--persist", "write the session to a 0600 file instead of printing a token")
.option("--password-stdin", "read the master password from stdin")
.option("--timeout <minutes>", "session lifetime when persisted", (v: string) => Number.parseInt(v, 10), 15)
@ -335,7 +361,7 @@ export function registerCredsCommands(parent: Command): void {
process.stdout.write(`Session written to ${path}, expiring in ${opts.timeout} minutes.\n`);
process.stdout.write(
"That file holds the key to this vault. Anything that can read it can read\n" +
"every item. Run `opencreds lock` when you are done.\n",
"every item. Run `" + cli + " lock` when you are done.\n",
);
return;
}
@ -433,7 +459,13 @@ export function registerCredsCommands(parent: Command): void {
// ---------------------------------------------------------------- items ---
const add = parent.command("add").description("add an item");
const add = parent
.command("add")
.description("add an item")
.addHelpText("after", examples(`
$CLI add login --name GitHub --username me --password - password read from stdin
$CLI add key --name DATABASE_URL --key-type env --value - one .env secret
$CLI add login --help every flag for one type`));
for (const type of ITEM_TYPE_NAMES) {
const sub = add
.command(type)
@ -484,11 +516,20 @@ export function registerCredsCommands(parent: Command): void {
parent
.command("list")
.description("list items; never prints secret values")
.addHelpText("after", examples(`
$CLI list everything (never shows values)
$CLI list --category db database credentials only
$CLI list -c social,api two categories at once
$CLI list --type login --search github`))
.option("--type <type>", "filter by item type")
.option("-c, --category <names>", "filter by category: db, social, server, api, … (comma-separated)")
.option("--folder <name>", "filter by folder")
.option("--search <text>", "match against the item name")
.option("--json", "machine-readable output, masked identically")
.action(async function (this: Command, opts: { type?: string; folder?: string; search?: string; json?: boolean }) {
.action(async function (
this: Command,
opts: { type?: string; category?: string; folder?: string; search?: string; json?: boolean },
) {
await run(async () => {
const store = storeFor(this);
const userKey = await unlock(store);
@ -500,9 +541,11 @@ export function registerCredsCommands(parent: Command): void {
const folderId = opts.folder ? folders.find((f) => f.name === opts.folder)?.id : undefined;
if (opts.folder && !folderId) fail(`No folder named "${opts.folder}"`, EXIT.USAGE);
const categories = categoriesOrFail(opts.category);
const needle = opts.search?.toLowerCase();
const filtered = items
.filter((item) => (opts.type ? item.type === opts.type : true))
.filter((item) => (categories ? categories.has(categorizeItem(item)) : true))
.filter((item) => (folderId ? item.folderId === folderId : true))
.filter((item) => (needle ? item.name.toLowerCase().includes(needle) : true))
.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
@ -523,6 +566,9 @@ export function registerCredsCommands(parent: Command): void {
.command("get")
.argument("<needle>", "item id or name")
.description("show one item, with every secret masked")
.addHelpText("after", examples(`
$CLI get GitHub the item, secrets masked
$CLI get GitHub --field login.password --reveal one value, in the clear`))
.option("--field <path>", "a single dotted field path, e.g. login.password")
.option("--reveal", "print the value of --field in the clear")
.option("--json", "machine-readable output, masked identically")
@ -649,22 +695,35 @@ export function registerCredsCommands(parent: Command): void {
parent
.command("export")
.description("export the vault as an OpenCreds database")
.addHelpText("after", examples(`
$CLI export --format csv --out vault.csv --yes everything, one flat row per item
$CLI export --format csv --category db --out db.csv --yes one category
$CLI export --out backup.opencreds encrypted backup (asks for a passphrase)`))
.option("--out <file>", "output file", `vault${DATABASE_EXTENSION}`)
.option("--passphrase-stdin", "read the export passphrase from stdin")
.option("--plaintext", "write every secret in the clear (requires --yes)")
.option("--format <format>", "opencreds or bitwarden-csv", "opencreds")
.option("--format <format>", "opencreds, csv (one flat row per item) or bitwarden-csv", "opencreds")
.option("-c, --category <names>", "only items in these categories: db, social, server, api, …")
.option("--yes", "confirm a plaintext export")
.action(async function (
this: Command,
opts: { out: string; passphraseStdin?: boolean; plaintext?: boolean; format: string; yes?: boolean },
opts: { out: string; passphraseStdin?: boolean; plaintext?: boolean; format: string; category?: string; yes?: boolean },
) {
await run(async () => {
if (!["opencreds", "csv", "bitwarden-csv"].includes(opts.format)) {
fail(`Unknown format "${opts.format}"; expected opencreds, csv or bitwarden-csv`, EXIT.USAGE);
}
if (opts.format !== "opencreds" && this.getOptionValueSource("out") === "default") opts.out = "vault.csv";
const categories = categoriesOrFail(opts.category);
const store = storeFor(this);
const meta = requireMeta(store);
const userKey = await unlock(store);
const payload = await loadPayload(store, userKey);
const loaded = await loadPayload(store, userKey);
const payload = categories
? { ...loaded, items: loaded.items.filter((item) => categories.has(categorizeItem(item))) }
: loaded;
const wantsPlaintext = Boolean(opts.plaintext) || opts.format === "bitwarden-csv";
const wantsPlaintext = Boolean(opts.plaintext) || opts.format !== "opencreds";
if (wantsPlaintext) {
process.stdout.write(
@ -677,6 +736,18 @@ export function registerCredsCommands(parent: Command): void {
}
}
if (opts.format === "csv") {
writeFileSync(opts.out, toSimpleCsv(payload.items, payload.folders), { encoding: "utf8", mode: 0o600 });
try {
chmodSync(opts.out, 0o600);
} catch {
/* no modes on this platform */
}
store.appendAudit(auditEvent({ action: "database.export_plaintext", itemCount: payload.items.length }));
process.stdout.write(`Wrote ${opts.out} — ${payload.items.length} items, every secret in the clear.\n`);
return;
}
if (opts.format === "bitwarden-csv") {
const { csv, dropped } = toBitwardenCsv(payload.items, payload.folders);
writeFileSync(opts.out, csv, { encoding: "utf8", mode: 0o600 });
@ -730,6 +801,10 @@ export function registerCredsCommands(parent: Command): void {
.command("import")
.argument("<file>", "an OpenCreds database, or a CSV export from another product")
.description("import into the vault")
.addHelpText("after", examples(`
$CLI import bitwarden.csv --dry-run see what would be imported
$CLI import bitwarden.csv import it (Bitwarden, 1Password, Chrome, LastPass or KeePass CSV)
$CLI import backup.opencreds restore an OpenCreds export`))
.option("--dry-run", "report what would happen and write nothing")
.option("--merge <strategy>", "skip, replace or duplicate", "skip")
.option("--source <name>", `force a CSV source (${Object.keys(IMPORT_SOURCES).join(", ")})`)

View file

@ -197,3 +197,16 @@ export type {
UriMatch,
VaultMeta,
} from "./types.js";
export {
SECRET_CATEGORIES,
SECRET_CATEGORY_IDS,
OTHER_CATEGORY,
categorizeSecret,
categorizeItem,
parseCategories,
csvLine,
toSimpleCsv,
SIMPLE_CSV_COLUMNS,
type SecretCategory,
} from "./categories.js";

View file

@ -8,7 +8,9 @@
*/
import { createInterface } from "node:readline";
import { stdin, stdout } from "node:process";
// Prompts go to stderr so stdout carries only the answer: `eval "$(opencreds
// unlock)"` then captures the export line and nothing else.
import { stdin, stderr } from "node:process";
/** Read a line with the terminal's echo turned off. */
export async function promptSecret(label: string): Promise<string> {
@ -18,7 +20,7 @@ export async function promptSecret(label: string): Promise<string> {
return readLineFromStdin();
}
const rl = createInterface({ input: stdin, output: stdout, terminal: true });
const rl = createInterface({ input: stdin, output: stderr, terminal: true });
const asMutable = rl as unknown as { output: { write: (chunk: string) => void }; _writeToOutput?: (s: string) => void };
let muted = false;
@ -51,7 +53,7 @@ export async function promptNewSecret(label: string, confirmLabel = "Repeat: "):
}
export async function promptLine(label: string): Promise<string> {
const rl = createInterface({ input: stdin, output: stdout });
const rl = createInterface({ input: stdin, output: stderr });
const answer = await new Promise<string>((resolve) => rl.question(label, resolve));
rl.close();
return answer;