mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
feat: encrypted secrets vault across CLI, desktop, dashboard, and backend
Local vault (apps/cli): clean-room PRMVLT01 format — Argon2id → XChaCha20-Poly1305, AEAD-bound header, atomic writes. vault:* / secrets:* / inject (primora:// refs) / agent credential broker (dummy tokens in the child, real secret on the wire) / TTL sessions / stack:* operator group. Project vault (backend): core.project_secrets, AES-256-GCM via the platform encryptor. Metadata-only list, audit-logged reveal, and secret://NAME refs resolved at job delivery — refs stored, values never. Surfaces: dashboard Vault page (+ local-vault card under the Tauri shell), desktop vault window via IPC to the primora binary, secrets:* --remote CLI mode, metadata/write-only MCP tools so agents consume secrets via secret:// refs without holding plaintext. Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
31bc23386b
commit
585bb04ab2
52 files changed
+3929
-142
No files matched your search
@@ -0,0 +1,317 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import * as p from "@clack/prompts";
|
||||
import { SecretsService, type ProjectSecret } from "@primora/api-client";
|
||||
|
||||
import { loadConfig } from "../config.js";
|
||||
import { CliError, configureClient, requireAuth, requireProject } from "../http.js";
|
||||
import { dim, formatDate, isJson, printJson, printTable, success, withSpinner } from "../out.js";
|
||||
import { saveVault } from "../vault.js";
|
||||
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
|
||||
|
||||
// Env-safe names only: `inject` and `stack --vault` place entries into a child
|
||||
// environment, so a name that is not a valid env var would fail there anyway.
|
||||
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
function checkName(name: string): void {
|
||||
if (!NAME_RE.test(name)) {
|
||||
throw new CliError(
|
||||
`Invalid secret name "${name}".`,
|
||||
"Use env-var-safe names: [A-Za-z_][A-Za-z0-9_]*",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Remote secrets live in the project's server-side store (AES-256-GCM at rest).
|
||||
// `--remote` switches every secrets:* verb to it; no vault password is needed.
|
||||
export interface RemoteOptions {
|
||||
remote?: boolean;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
function remoteProject(options: RemoteOptions): string {
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
return requireProject(cfg, options.project);
|
||||
}
|
||||
|
||||
function remoteRow(s: ProjectSecret): string[] {
|
||||
return [s.name, s.url || "—", s.notes || "—", formatDate(s.updated_at)];
|
||||
}
|
||||
|
||||
export interface SetOptions extends VaultPasswordOptions, RemoteOptions {
|
||||
value?: string;
|
||||
url?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export async function cmdSecretsSet(name: string, options: SetOptions): Promise<void> {
|
||||
checkName(name);
|
||||
let value = options.value;
|
||||
if (value === undefined && !process.stdin.isTTY) {
|
||||
value = readFileSync(0, "utf8").replace(/\r?\n$/, "");
|
||||
}
|
||||
if (value === undefined) {
|
||||
const entered = await p.password({ message: `Value for ${name}` });
|
||||
if (p.isCancel(entered)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
value = entered;
|
||||
}
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
await SecretsService.setProjectSecret({
|
||||
projectId,
|
||||
name,
|
||||
requestBody: { value, url: options.url, notes: options.notes },
|
||||
});
|
||||
success(`Set ${name} in the project vault`);
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
const now = new Date().toISOString();
|
||||
const existing = vault.data.secrets[name];
|
||||
vault.data.secrets[name] = {
|
||||
value,
|
||||
url: options.url ?? existing?.url,
|
||||
notes: options.notes ?? existing?.notes,
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
await withSpinner("Encrypting vault…", async () => saveVault(vault));
|
||||
success(`${existing ? "Updated" : "Set"} ${name}`);
|
||||
}
|
||||
|
||||
async function pickRemote(projectId: string, message: string): Promise<string> {
|
||||
const { items } = await SecretsService.listProjectSecrets({ projectId });
|
||||
if (!items.length) {
|
||||
dim("Project vault is empty — `primora secrets set NAME --remote`.");
|
||||
process.exit(0);
|
||||
}
|
||||
const picked = await p.select({
|
||||
message,
|
||||
options: items.map((s) => ({ value: s.name, label: s.name, hint: s.url || s.notes || undefined })),
|
||||
});
|
||||
if (p.isCancel(picked)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
export async function cmdSecretsGet(
|
||||
name: string | undefined,
|
||||
options: VaultPasswordOptions & RemoteOptions & { json?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
name ??= await pickRemote(projectId, "Secret to reveal");
|
||||
const res = await SecretsService.revealProjectSecret({ projectId, name });
|
||||
if (isJson(options)) {
|
||||
printJson({ name, value: res.value });
|
||||
return;
|
||||
}
|
||||
process.stdout.write(res.value + "\n");
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
if (!name) {
|
||||
if (!process.stdin.isTTY) throw new CliError("Usage: primora secrets get <name>");
|
||||
const names = Object.keys(vault.data.secrets).sort();
|
||||
if (!names.length) {
|
||||
dim("Vault is empty — `primora secrets set NAME`.");
|
||||
return;
|
||||
}
|
||||
const picked = await p.select({
|
||||
message: "Secret to print",
|
||||
options: names.map((n) => ({
|
||||
value: n,
|
||||
label: n,
|
||||
hint: vault.data.secrets[n].url ?? vault.data.secrets[n].notes ?? undefined,
|
||||
})),
|
||||
});
|
||||
if (p.isCancel(picked)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
name = picked;
|
||||
}
|
||||
const entry = vault.data.secrets[name];
|
||||
if (!entry) throw new CliError(`Secret "${name}" not found.`);
|
||||
if (isJson(options)) {
|
||||
printJson({
|
||||
name,
|
||||
value: entry.value,
|
||||
url: entry.url ?? null,
|
||||
notes: entry.notes ?? null,
|
||||
created_at: entry.created_at,
|
||||
updated_at: entry.updated_at,
|
||||
});
|
||||
return;
|
||||
}
|
||||
process.stdout.write(entry.value + "\n");
|
||||
}
|
||||
|
||||
export async function cmdSecretsList(
|
||||
options: VaultPasswordOptions & RemoteOptions & { json?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
const { items } = await SecretsService.listProjectSecrets({ projectId });
|
||||
if (isJson(options)) {
|
||||
printJson(items);
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
dim("Project vault is empty — `primora secrets set NAME --remote`.");
|
||||
return;
|
||||
}
|
||||
printTable(["NAME", "URL", "NOTES", "UPDATED"], items.map(remoteRow));
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
const rows = Object.entries(vault.data.secrets).sort(([a], [b]) => a.localeCompare(b));
|
||||
if (isJson(options)) {
|
||||
printJson(
|
||||
rows.map(([name, e]) => ({
|
||||
name,
|
||||
url: e.url ?? null,
|
||||
notes: e.notes ?? null,
|
||||
created_at: e.created_at,
|
||||
updated_at: e.updated_at,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!rows.length) {
|
||||
dim("Vault is empty — `primora secrets set NAME` or `primora secrets import .env`.");
|
||||
return;
|
||||
}
|
||||
printTable(
|
||||
["NAME", "URL", "UPDATED"],
|
||||
rows.map(([name, e]) => [name, e.url ?? "—", formatDate(e.updated_at)]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdSecretsRemove(
|
||||
name: string | undefined,
|
||||
options: VaultPasswordOptions & RemoteOptions & { yes?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
name ??= await pickRemote(projectId, "Secret to delete");
|
||||
if (!options.yes && process.stdin.isTTY) {
|
||||
const confirmed = await p.confirm({ message: `Delete project secret ${name}?` });
|
||||
if (p.isCancel(confirmed) || !confirmed) return;
|
||||
}
|
||||
await SecretsService.deleteProjectSecret({ projectId, name });
|
||||
success(`Deleted ${name}`);
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
if (!name) {
|
||||
if (!process.stdin.isTTY) throw new CliError("Usage: primora secrets rm <name>");
|
||||
const names = Object.keys(vault.data.secrets).sort();
|
||||
if (!names.length) {
|
||||
dim("Vault is empty.");
|
||||
return;
|
||||
}
|
||||
const picked = await p.select({
|
||||
message: "Secret to delete",
|
||||
options: names.map((n) => ({ value: n, label: n })),
|
||||
});
|
||||
if (p.isCancel(picked)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
name = picked;
|
||||
}
|
||||
if (!vault.data.secrets[name]) throw new CliError(`Secret "${name}" not found.`);
|
||||
if (!options.yes && process.stdin.isTTY) {
|
||||
const confirmed = await p.confirm({ message: `Delete secret ${name}?` });
|
||||
if (p.isCancel(confirmed) || !confirmed) return;
|
||||
}
|
||||
delete vault.data.secrets[name];
|
||||
await withSpinner("Encrypting vault…", async () => saveVault(vault));
|
||||
success(`Deleted ${name}`);
|
||||
}
|
||||
|
||||
/** Parse a .env file: KEY=value lines, optional `export`, quotes stripped. */
|
||||
export function parseEnvFile(path: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const raw of readFileSync(path, "utf8").split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
const key = line.slice(0, eq).trim().replace(/^export\s+/, "");
|
||||
let val = line.slice(eq + 1).trim();
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
out[key] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function cmdSecretsImport(
|
||||
file: string,
|
||||
options: VaultPasswordOptions & RemoteOptions & { overwrite?: boolean },
|
||||
): Promise<void> {
|
||||
const parsed = parseEnvFile(file);
|
||||
const entries = Object.entries(parsed).filter(([k]) => NAME_RE.test(k));
|
||||
if (!entries.length) throw new CliError(`No valid KEY=value pairs in ${file}.`);
|
||||
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
const { items } = await SecretsService.listProjectSecrets({ projectId });
|
||||
const existing = new Set(items.map((s) => s.name));
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const [key, value] of entries) {
|
||||
if (existing.has(key) && !options.overwrite) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
await SecretsService.setProjectSecret({ projectId, name: key, requestBody: { value } });
|
||||
added++;
|
||||
}
|
||||
success(`Imported ${added} secret${added === 1 ? "" : "s"} into the project vault`);
|
||||
if (skipped) dim(`Skipped ${skipped} existing (pass --overwrite to replace).`);
|
||||
if (entries.length < Object.keys(parsed).length) {
|
||||
dim("Skipped keys that are not env-var-safe.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const vault = await unlockVault(options);
|
||||
const now = new Date().toISOString();
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const [key, value] of entries) {
|
||||
const existing = vault.data.secrets[key];
|
||||
if (existing && !options.overwrite) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
vault.data.secrets[key] = {
|
||||
value,
|
||||
url: existing?.url,
|
||||
notes: existing?.notes,
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
added++;
|
||||
}
|
||||
await withSpinner("Encrypting vault…", async () => saveVault(vault));
|
||||
success(`Imported ${added} secret${added === 1 ? "" : "s"} from ${file}`);
|
||||
if (skipped) dim(`Skipped ${skipped} existing (pass --overwrite to replace).`);
|
||||
if (entries.length < Object.keys(parsed).length) {
|
||||
dim("Skipped keys that are not env-var-safe.");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user