mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-04 05:37:10 +00:00
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>
91 lines
3.3 KiB
TypeScript
91 lines
3.3 KiB
TypeScript
/**
|
|
* Terminal input for secrets.
|
|
*
|
|
* A master password must not appear in the shell history, the process list, or
|
|
* the terminal scrollback, which rules out an argument, an environment variable
|
|
* and an unmuted read. So: read from the TTY with echo off, and offer stdin for
|
|
* the scripted case.
|
|
*/
|
|
|
|
import { createInterface } from "node:readline";
|
|
// 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> {
|
|
if (!stdin.isTTY) {
|
|
// Not a terminal: read one line from stdin instead of failing. This is the
|
|
// `echo … | opencreds …` path, and it is why every secret flag accepts `-`.
|
|
return readLineFromStdin();
|
|
}
|
|
|
|
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;
|
|
asMutable._writeToOutput = function write(chunk: string): void {
|
|
if (!muted) {
|
|
asMutable.output.write(chunk);
|
|
return;
|
|
}
|
|
// Echo nothing at all rather than asterisks: a length is information, and
|
|
// it is the one piece of a password an observer gets for free otherwise.
|
|
if (chunk.includes("\n")) asMutable.output.write("\n");
|
|
};
|
|
|
|
const answer = await new Promise<string>((resolve) => {
|
|
rl.question(label, (value) => resolve(value));
|
|
muted = true;
|
|
});
|
|
muted = false;
|
|
rl.close();
|
|
return answer;
|
|
}
|
|
|
|
/** Ask twice and require agreement. A typo'd master password is an empty vault. */
|
|
export async function promptNewSecret(label: string, confirmLabel = "Repeat: "): Promise<string> {
|
|
const first = await promptSecret(label);
|
|
if (first.length === 0) throw new Error("A password is required");
|
|
const second = await promptSecret(confirmLabel);
|
|
if (first !== second) throw new Error("The two entries did not match");
|
|
return first;
|
|
}
|
|
|
|
export async function promptLine(label: string): Promise<string> {
|
|
const rl = createInterface({ input: stdin, output: stderr });
|
|
const answer = await new Promise<string>((resolve) => rl.question(label, resolve));
|
|
rl.close();
|
|
return answer;
|
|
}
|
|
|
|
/** A yes/no gate. Anything but an explicit yes is a no. */
|
|
export async function confirm(question: string): Promise<boolean> {
|
|
if (!stdin.isTTY) return false;
|
|
const answer = await promptLine(`${question} [y/N] `);
|
|
return /^y(es)?$/i.test(answer.trim());
|
|
}
|
|
|
|
export function readLineFromStdin(): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
let data = "";
|
|
stdin.setEncoding("utf8");
|
|
stdin.on("data", (chunk) => {
|
|
data += chunk;
|
|
});
|
|
stdin.on("end", () => resolve(data.replace(/\r?\n$/, "")));
|
|
stdin.on("error", reject);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve a flag value that may be `-`, meaning "read it from stdin".
|
|
*
|
|
* Every secret-bearing flag goes through here, so a secret need never appear in
|
|
* an argument vector that `ps` will happily print to anyone on the box.
|
|
*/
|
|
export async function resolveSecretFlag(value: string | undefined): Promise<string | undefined> {
|
|
if (value === undefined) return undefined;
|
|
if (value === "-") return readLineFromStdin();
|
|
return value;
|
|
}
|