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:
Tomas DvorakandDevin committed 2026-09-24 18:46:37 +02:00
1 parent 31bc23386b
commit 585bb04ab2
52 files changed
+3929 -142

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
import { spawn } from "node:child_process";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { Readable } from "node:stream";
import { CliError } from "../http.js";
import { dim } from "../out.js";
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
/**
* `primora agent` — loopback credential broker. Wraps one child process with
* dummy tokens; the proxy attaches the real secret on the wire per grant.
* The child (an AI agent, a script) never holds a real key.
*
* Transport note: this is a base-URL broker, not a TLS MITM — tools must honor
* their *_BASE_URL / registry / api-url override (OpenAI, Anthropic, Groq,
* DeepSeek, xAI, OpenRouter, npm do; `gh` hardcodes api.github.com). That is
* the documented ceiling — a CA-trusted MITM is a much larger build.
* ponytail: no standalone daemon (`agent start`) — wrap mode only.
*/
interface Grant {
upstream: string; // e.g. https://api.anthropic.com
header: (secret: string) => [string, string];
}
interface Preset extends Grant {
env: (base: string) => Record<string, string>; // child env; base = http://127.0.0.1:port/<name>
}
const DUMMY = "primora-brokered";
const bearer = (s: string): [string, string] => ["authorization", `Bearer ${s}`];
const PRESETS: Record<string, Preset> = {
anthropic: {
upstream: "https://api.anthropic.com",
header: (s) => ["x-api-key", s],
env: (b) => ({ ANTHROPIC_BASE_URL: b, ANTHROPIC_API_KEY: DUMMY }),
},
openai: {
upstream: "https://api.openai.com",
header: bearer,
env: (b) => ({ OPENAI_BASE_URL: `${b}/v1`, OPENAI_API_KEY: DUMMY }),
},
openrouter: {
upstream: "https://openrouter.ai",
header: bearer,
env: (b) => ({ OPENROUTER_BASE_URL: `${b}/api`, OPENROUTER_API_KEY: DUMMY }),
},
groq: {
upstream: "https://api.groq.com",
header: bearer,
env: (b) => ({ GROQ_BASE_URL: `${b}/openai`, GROQ_API_KEY: DUMMY }),
},
deepseek: {
upstream: "https://api.deepseek.com",
header: bearer,
env: (b) => ({ DEEPSEEK_BASE_URL: b, DEEPSEEK_API_KEY: DUMMY }),
},
xai: {
upstream: "https://api.x.ai",
header: bearer,
env: (b) => ({ XAI_BASE_URL: `${b}/v1`, XAI_API_KEY: DUMMY }),
},
github: {
upstream: "https://api.github.com",
header: bearer,
env: (b) => ({ GITHUB_API_URL: b, GH_TOKEN: DUMMY, GITHUB_TOKEN: DUMMY }),
},
npm: {
upstream: "https://registry.npmjs.org",
header: bearer,
env: (b) => ({ NPM_CONFIG_REGISTRY: b, NPM_TOKEN: DUMMY }),
},
};
export const PRESET_NAMES = Object.keys(PRESETS);
export interface AgentOptions extends VaultPasswordOptions {
ttl?: string;
upstream?: string | string[];
[preset: string]: unknown; // cac preset flags land here
}
const HOP_BY_HOP = new Set([
"host",
"connection",
"content-length",
"transfer-encoding",
"keep-alive",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"upgrade",
]);
async function proxy(
req: IncomingMessage,
res: ServerResponse,
grants: Map<string, { grant: Grant; secret: string }>,
): Promise<void> {
const match = /^\/([a-z0-9_-]+)(\/.*)?$/i.exec(req.url ?? "");
const entry = match ? grants.get(match[1].toLowerCase()) : undefined;
if (!match || !entry) {
res.writeHead(404, { "content-type": "text/plain" }).end("primora agent: no grant for this route");
return;
}
const suffix = match[2] ?? "/";
const headers: Record<string, string> = {};
for (const [k, v] of Object.entries(req.headers)) {
const lk = k.toLowerCase();
if (HOP_BY_HOP.has(lk) || Array.isArray(v) || v === undefined) continue;
headers[lk] = v;
}
const [hk, hv] = entry.grant.header(entry.secret);
headers[hk] = hv; // real secret attached here — the child sent a dummy
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
const body = chunks.length ? Buffer.concat(chunks) : undefined;
try {
const up = await fetch(entry.grant.upstream + suffix, {
method: req.method,
headers,
body,
redirect: "manual",
});
const out: Record<string, string> = {};
up.headers.forEach((v, k) => {
if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v;
});
res.writeHead(up.status, out);
if (up.body) Readable.fromWeb(up.body as never).pipe(res);
else res.end();
} catch (e) {
res.writeHead(502).end(`primora agent: upstream error — ${(e as Error).message}`);
}
}
export async function cmdAgent(options: AgentOptions): Promise<void> {
const sep = process.argv.indexOf("--");
const child = sep >= 0 ? process.argv.slice(sep + 1) : [];
if (!child.length) {
throw new CliError(
"No command given.",
"Usage: primora agent --anthropic VAULT_KEY [--ttl 900] -- <cmd>",
);
}
// Collect grants: --<preset> <vault-name> plus custom --upstream name=url.
const wanted: { name: string; grant: Grant; env?: Preset["env"]; secretName: string }[] = [];
for (const name of PRESET_NAMES) {
const secretName = options[name];
if (typeof secretName === "string" && secretName) {
wanted.push({ name, grant: PRESETS[name], env: PRESETS[name].env, secretName });
}
}
const customs = ([] as string[]).concat(options.upstream ?? []);
for (const spec of customs) {
const eq = spec.indexOf("=");
const url = spec.slice(eq + 1);
// HTTPS for real upstreams; HTTP only for loopback dev servers.
const urlOk =
/^https:\/\//.test(url) || /^http:\/\/(127\.|localhost|\[::1\])/.test(url);
if (eq < 1 || !urlOk) {
throw new CliError(
`Bad --upstream "${spec}".`,
"Format: name=https://api.host (http allowed for 127.0.0.1/localhost only) — Authorization: Bearer is attached.",
);
}
const name = spec.slice(0, eq).toLowerCase();
const upstream = spec.slice(eq + 1).replace(/\/+$/, "");
wanted.push({
name,
grant: { upstream, header: bearer },
env: (b) => ({
[`${name.toUpperCase().replace(/-/g, "_")}_BASE_URL`]: b,
[`${name.toUpperCase().replace(/-/g, "_")}_TOKEN`]: DUMMY,
}),
secretName: name.toUpperCase().replace(/-/g, "_") + "_KEY",
});
}
if (!wanted.length) {
throw new CliError(
"No grants.",
`Pass at least one: ${PRESET_NAMES.map((n) => `--${n} <vault-key>`).join(" ")} or --upstream name=https://…`,
);
}
const vault = await unlockVault(options);
const grants = new Map<string, { grant: Grant; secret: string }>();
for (const w of wanted) {
const entry = vault.data.secrets[w.secretName];
if (!entry) {
throw new CliError(
`Secret "${w.secretName}" not in vault for grant "${w.name}".`,
`Run \`primora secrets set ${w.secretName}\` first.`,
);
}
grants.set(w.name, { grant: w.grant, secret: entry.value });
}
const server = createServer((req, res) => void proxy(req, res, grants));
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const port = (server.address() as { port: number }).port;
const base = `http://127.0.0.1:${port}`;
const ttl = Math.min(Math.max(60, Number(options.ttl ?? 900)), 3600);
const timer = setTimeout(() => {
server.close();
process.stderr.write("primora agent: grant TTL expired — broker stopped\n");
process.exit(0);
}, ttl * 1000);
const env: NodeJS.ProcessEnv = { ...process.env };
for (const w of wanted) Object.assign(env, w.env?.(`${base}/${w.name}`));
dim(`broker on ${base} — grants: ${wanted.map((w) => w.name).join(", ")} (ttl ${ttl}s)`);
// Async spawn — spawnSync would block the event loop and starve the proxy.
const code = await new Promise<number>((resolve, reject) => {
const proc = spawn(child[0], child.slice(1), { env, stdio: "inherit" });
proc.on("error", (e) => {
server.close();
reject(new CliError(`Failed to start ${child[0]}: ${e.message}`));
});
proc.on("exit", (c) => resolve(c ?? 1));
});
clearTimeout(timer);
server.close();
process.exit(code);
}
+61
View File
@@ -0,0 +1,61 @@
import { spawnSync } from "node:child_process";
import { CliError } from "../http.js";
import { dim } from "../out.js";
import { parseEnvFile } from "./secrets.js";
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
const REF_RE = /^primora:\/\/(.+)$/;
/**
* `primora inject [--all] [--env-file PATH] -- <cmd>` — run one child process
* with vault secrets in its environment. Nothing written to disk, nothing in
* argv; the parent shell is unchanged. `primora://NAME` values inside the
* env-file resolve against the vault; literals stay literals.
*/
export async function cmdInject(
options: VaultPasswordOptions & { all?: boolean; envFile?: string },
): Promise<void> {
// cac does not model `--` passthrough — slice it out of argv directly.
const sep = process.argv.indexOf("--");
const child = sep >= 0 ? process.argv.slice(sep + 1) : [];
if (!child.length) {
throw new CliError("No command given.", "Usage: primora inject [--all] [--env-file .env] -- <cmd>");
}
const vault = await unlockVault(options);
const env: Record<string, string> = {};
// Default: whole vault. --all exists for readability when --env-file is also used.
if (options.all || !options.envFile) {
for (const [k, e] of Object.entries(vault.data.secrets)) env[k] = e.value;
}
if (options.envFile) {
for (const [key, raw] of Object.entries(parseEnvFile(options.envFile))) {
const ref = REF_RE.exec(raw);
if (!ref) {
env[key] = raw;
continue;
}
const entry = vault.data.secrets[ref[1]];
if (!entry) throw new CliError(`Unresolved ref: primora://${ref[1]} in ${options.envFile}`);
env[key] = entry.value;
}
}
dim(`injecting ${Object.keys(env).length} var${Object.keys(env).length === 1 ? "" : "s"} → ${child[0]}`);
const res = spawnSync(child[0], child.slice(1), {
env: { ...process.env, ...env } as NodeJS.ProcessEnv,
stdio: "inherit",
});
if (res.error) throw new CliError(`Failed to start ${child[0]}: ${res.error.message}`);
process.exit(res.status ?? 1);
}
/** Used by `stack up --vault`: render all vault entries as .env lines. */
export async function vaultAsEnvFile(
options: VaultPasswordOptions,
): Promise<Record<string, string>> {
const vault = await unlockVault(options);
return Object.fromEntries(Object.entries(vault.data.secrets).map(([k, e]) => [k, e.value]));
}
+317
View File
@@ -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.");
}
}
+142
View File
@@ -0,0 +1,142 @@
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { CliError } from "../http.js";
import { dim, success } from "../out.js";
import { vaultAsEnvFile } from "./inject.js";
import { parseEnvFile } from "./secrets.js";
import type { VaultPasswordOptions } from "./vault.js";
interface StackOptions extends VaultPasswordOptions {
dir?: string;
}
function projectDir(options: StackOptions): string {
const dir = options.dir ?? process.cwd();
for (const name of ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]) {
if (existsSync(join(dir, name))) return dir;
}
throw new CliError(
`No compose file in ${dir}.`,
"Run from a Primora deployment directory or pass --dir.",
);
}
function compose(dir: string, args: string[], env?: NodeJS.ProcessEnv): number {
const res = spawnSync("docker", ["compose", ...args], {
cwd: dir,
stdio: "inherit",
env: env ?? process.env,
});
if (res.error) {
throw new CliError(
`Failed to run docker compose: ${res.error.message}`,
"Docker with the Compose plugin is required.",
);
}
return res.status ?? 1;
}
export async function cmdStackUp(
options: StackOptions & { vault?: boolean; force?: boolean; detach?: boolean },
): Promise<void> {
const dir = projectDir(options);
const envPath = join(dir, ".env");
if (!options.vault) {
const code = compose(dir, ["up", "-d"]);
if (code !== 0) process.exit(code);
success("Stack is up.");
return;
}
// Vault mode: materialize .env for the duration of compose, then remove it.
// `env_file:` directives make compose require the file on disk — there is no
// flag to feed it via the process environment alone.
const secrets = await vaultAsEnvFile(options);
const hadEnv = existsSync(envPath);
if (hadEnv && !options.force) {
throw new CliError(
`${envPath} already exists.`,
"Import it (`primora secrets import .env --overwrite`), remove it, or pass --force to swap it for the vault during this run.",
);
}
const prior = hadEnv ? readFileSync(envPath) : undefined;
// compose env_file parsing is literal — write raw KEY=value lines.
const lines = Object.entries(secrets).map(([k, v]) => {
if (v.includes("\n")) throw new CliError(`Secret ${k} contains a newline — cannot be an env var.`);
return `${k}=${v}`;
});
try {
writeFileSync(envPath, lines.join("\n") + "\n", { mode: 0o600 });
const code = compose(dir, ["up", "-d"]);
if (code !== 0) process.exit(code);
success("Stack is up — .env restored/removed; secrets live in the vault only.");
} finally {
if (prior) writeFileSync(envPath, prior, { mode: 0o600 });
else if (existsSync(envPath)) unlinkSync(envPath);
}
}
export async function cmdStackDown(options: StackOptions): Promise<void> {
const dir = projectDir(options);
const code = compose(dir, ["down"]);
if (code !== 0) process.exit(code);
success("Stack stopped.");
}
export async function cmdStackStatus(options: StackOptions): Promise<void> {
const code = compose(projectDir(options), ["ps"]);
if (code !== 0) process.exit(code);
}
export async function cmdStackLogs(
service: string | undefined,
options: StackOptions & { follow?: boolean; tail?: string },
): Promise<void> {
const args = ["logs", "--tail", options.tail ?? "200"];
if (options.follow) args.push("--follow");
if (service) args.push(service);
const code = compose(projectDir(options), args);
if (code !== 0) process.exit(code);
}
export async function cmdStackPull(options: StackOptions): Promise<void> {
const dir = projectDir(options);
const code = compose(dir, ["pull"]);
if (code !== 0) process.exit(code);
success("Images updated — run `primora stack up` to apply.");
}
/** Postgres dump via `compose exec`; for a full backup (DB + storage volume)
* use scripts/backup.sh — it covers both. ponytail: no storage tar here. */
export async function cmdStackBackup(
options: StackOptions & { out?: string },
): Promise<void> {
const dir = projectDir(options);
const envFile = join(dir, ".env");
const env = existsSync(envFile) ? parseEnvFile(envFile) : {};
const user = env.POSTGRES_USER ?? "primora";
const db = env.POSTGRES_DB ?? "primora";
const outDir = options.out ?? join(dir, "backups");
mkdirSync(outDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const outPath = join(outDir, `primora-${stamp}.dump`);
const res = spawnSync(
"docker",
["compose", "exec", "-T", "postgres", "pg_dump", "-U", user, "-Fc", db],
{ cwd: dir, encoding: "buffer", maxBuffer: 1024 * 1024 * 1024 },
);
if (res.error || res.status !== 0) {
throw new CliError(
`pg_dump failed: ${res.error?.message ?? res.stderr?.toString().slice(0, 300) ?? `exit ${res.status}`}`,
"Is the stack running? `primora stack status`",
);
}
writeFileSync(outPath, res.stdout as Buffer, { mode: 0o600 });
success(`Backup written to ${outPath}`);
dim("Postgres only — scripts/backup.sh also archives the storage volume.");
}
+166
View File
@@ -0,0 +1,166 @@
import * as p from "@clack/prompts";
import pc from "picocolors";
import { CliError } from "../http.js";
import { dim, isJson, printJson, success, withSpinner } from "../out.js";
import {
clearSession,
createVault,
loadSessionKey,
openVault,
openVaultWithKey,
saveSessionKey,
sessionExpiry,
vaultExists,
vaultHeader,
vaultPath,
type OpenVault,
} from "../vault.js";
export interface VaultPasswordOptions {
passwordFile?: string;
}
async function promptPassword(confirm: boolean): Promise<Uint8Array> {
const entered = await p.password({ message: "Vault password" });
if (p.isCancel(entered)) {
p.cancel("Cancelled.");
process.exit(0);
}
if (confirm) {
const again = await p.password({ message: "Confirm vault password" });
if (p.isCancel(again)) {
p.cancel("Cancelled.");
process.exit(0);
}
if (again !== entered) throw new CliError("Passwords do not match.");
}
return new TextEncoder().encode(entered);
}
async function readPassword(options: VaultPasswordOptions): Promise<Uint8Array | undefined> {
const env = process.env.PRIMORA_VAULT_PASSWORD;
if (env) return new TextEncoder().encode(env);
if (options.passwordFile) {
const { readFileSync } = await import("node:fs");
const pw = readFileSync(options.passwordFile, "utf8").replace(/\r?\n$/, "");
return new TextEncoder().encode(pw);
}
return undefined;
}
/**
* Open the vault: live session key → PRIMORA_VAULT_PASSWORD → --password-file →
* hidden prompt. The session is how agents and scripts use the vault without
* ever seeing the password; everything else still proves the password.
*/
export async function unlockVault(options: VaultPasswordOptions): Promise<OpenVault> {
const session = loadSessionKey();
if (session) return openVaultWithKey(session);
const password = await readPassword(options);
if (password) return withSpinner("Unlocking vault…", async () => openVault(password));
if (!process.stdin.isTTY) {
throw new CliError(
"Vault is locked.",
"Run `primora vault unlock`, or set PRIMORA_VAULT_PASSWORD / pass --password-file.",
);
}
const prompted = await promptPassword(false);
return withSpinner("Unlocking vault…", async () => openVault(prompted));
}
export async function cmdVaultInit(options: VaultPasswordOptions): Promise<void> {
let password = await readPassword(options);
if (!password) {
if (!process.stdin.isTTY) {
throw new CliError(
"Vault password required.",
"Set PRIMORA_VAULT_PASSWORD or pass --password-file in non-interactive mode.",
);
}
password = await promptPassword(true);
}
await withSpinner("Creating vault…", async () => createVault(password));
success(`Vault created at ${vaultPath()}`);
dim("Secrets are encrypted with Argon2id + XChaCha20-Poly1305. The password is not recoverable.");
}
export async function cmdVaultUnlock(
options: VaultPasswordOptions & { ttl?: string },
): Promise<void> {
const session = loadSessionKey();
if (session) {
const exp = sessionExpiry();
dim(`Already unlocked — expires ${exp ? new Date(exp * 1000).toISOString() : "soon"}.`);
return;
}
const password =
(await readPassword(options)) ??
(process.stdin.isTTY
? await promptPassword(false)
: (() => {
throw new CliError(
"Vault password required.",
"Set PRIMORA_VAULT_PASSWORD or pass --password-file.",
);
})());
const vault = await withSpinner("Unlocking vault…", async () => openVault(password)); // proves the password before stashing the key
const ttl = saveSessionKey(vault.key, Number(options.ttl ?? 900));
success(`Vault unlocked for ${ttl}s — secrets usable without the password until expiry.`);
dim("`primora vault lock` revokes immediately.");
}
export async function cmdVaultLock(): Promise<void> {
if (clearSession()) success("Vault locked — session revoked.");
else dim("No active session.");
}
export async function cmdVaultStatus(options: { json?: boolean }): Promise<void> {
const exists = vaultExists();
const header = exists ? vaultHeader() : undefined;
const exp = sessionExpiry();
const unlocked = exp !== undefined;
let count: number | undefined;
if (unlocked) {
try {
count = Object.keys(openVaultWithKey(loadSessionKey()!).data.secrets).length;
} catch {
// session key no longer matches (vault re-created) — report locked
}
}
if (isJson(options)) {
printJson({
path: vaultPath(),
exists,
unlocked,
expires_at: exp ?? null,
secrets: count ?? null,
kdf: header ?? null,
});
return;
}
if (!exists) {
dim(`No vault at ${vaultPath()} — run \`primora vault init\`.`);
return;
}
const remaining = exp ? Math.max(0, exp - Math.floor(Date.now() / 1000)) : 0;
const mins = Math.floor(remaining / 60);
const state = unlocked
? pc.green("● unlocked") +
pc.dim(` — ${mins ? `${mins}m ` : ""}${remaining % 60}s remaining`)
: pc.dim("● locked");
process.stdout.write(`vault ${state}\n\n`);
const rows: [string, string][] = [
["path", vaultPath()],
["kdf", `argon2id · ${header!.m} KiB · ${header!.t} passes · ${header!.p} lanes`],
];
if (unlocked) rows.push(["secrets", String(count ?? "?")]);
const width = Math.max(...rows.map(([k]) => k.length));
for (const [k, v] of rows) {
process.stdout.write(`${pc.dim(k.padEnd(width))} ${v}\n`);
}
if (!unlocked) dim(`\n\`primora vault unlock\` opens a session for agents and scripts.`);
}