mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
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>
143 lines
5.1 KiB
TypeScript
143 lines
5.1 KiB
TypeScript
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.");
|
|
}
|