secret/secret.ts
2026-04-06 23:06:45 +00:00

509 lines
14 KiB
TypeScript

#!/usr/bin/env bun
// @openclaw/secret - Infisical secret management CLI with local caching
// Usage: secret <command> [args]
// ─── Colors ────────────────────────────────────────────────────────────────────
const c = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
gray: "\x1b[90m",
};
function ok(msg: string) {
console.log(`${c.green}${c.reset} ${msg}`);
}
function info(msg: string) {
console.log(`${c.cyan}${c.reset} ${msg}`);
}
function warn(msg: string) {
console.log(`${c.yellow}${c.reset} ${msg}`);
}
function fail(msg: string) {
console.error(`${c.red}${c.reset} ${msg}`);
}
// ─── Paths ─────────────────────────────────────────────────────────────────────
const HOME = Bun.env.HOME || "~";
const CONFIG_DIR = `${HOME}/.config/oc-secret`;
const CONFIG_PATH = `${CONFIG_DIR}/config.json`;
const CACHE_PATH = `${CONFIG_DIR}/cache.json`;
// ─── Types ─────────────────────────────────────────────────────────────────────
interface Config {
clientId: string;
clientSecret: string;
projectId: string;
env: string;
ttlMs: number;
}
interface CacheEntry {
value: string;
updatedAt: number;
}
interface Cache {
secrets: Record<string, CacheEntry>;
lastSync: number;
ttlMs: number;
}
// ─── Config ────────────────────────────────────────────────────────────────────
async function loadConfig(): Promise<Config> {
let fileConfig: Partial<Config> = {};
const configFile = Bun.file(CONFIG_PATH);
if (await configFile.exists()) {
try {
fileConfig = await configFile.json();
} catch {
warn("config.json is malformed, using env vars only");
}
}
const clientId =
Bun.env.INFISICAL_CLIENT_ID || fileConfig.clientId || "";
const clientSecret =
Bun.env.INFISICAL_CLIENT_SECRET || fileConfig.clientSecret || "";
const projectId =
Bun.env.INFISICAL_PROJECT_ID ||
fileConfig.projectId ||
"";
const env = Bun.env.INFISICAL_ENV || fileConfig.env || "dev";
const ttlMs = fileConfig.ttlMs || 86400000; // 24h
if (!clientId || !clientSecret) {
fail(
"Missing Infisical credentials. Set INFISICAL_CLIENT_ID/INFISICAL_CLIENT_SECRET or add them to config.json"
);
process.exit(1);
}
if (!projectId) {
fail(
"Missing Infisical project ID. Set INFISICAL_PROJECT_ID or add projectId to config.json"
);
process.exit(1);
}
return { clientId, clientSecret, projectId, env, ttlMs };
}
// ─── Cache ─────────────────────────────────────────────────────────────────────
async function loadCache(): Promise<Cache> {
const cacheFile = Bun.file(CACHE_PATH);
if (await cacheFile.exists()) {
try {
const data = await cacheFile.json();
if (data && typeof data.secrets === "object") {
return data as Cache;
}
} catch {
warn("Cache file corrupted, starting fresh");
}
}
return { secrets: {}, lastSync: 0, ttlMs: 86400000 };
}
async function saveCache(cache: Cache): Promise<void> {
await Bun.write(CACHE_PATH, JSON.stringify(cache, null, 2));
// Set file permissions to 600
const proc = Bun.spawn(["chmod", "600", CACHE_PATH]);
await proc.exited;
}
function isCacheValid(entry: CacheEntry, ttlMs: number): boolean {
return Date.now() - entry.updatedAt < ttlMs;
}
// ─── Infisical API ─────────────────────────────────────────────────────────────
const API_BASE = "https://app.infisical.com/api";
async function authenticate(config: Config): Promise<string> {
const res = await fetch(`${API_BASE}/v1/auth/universal-auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: config.clientId,
clientSecret: config.clientSecret,
}),
});
if (!res.ok) {
const body = await res.text();
if (res.status === 401 || res.status === 403) {
fail("Authentication failed — check your clientId/clientSecret");
} else {
fail(`Auth request failed (${res.status}): ${body}`);
}
process.exit(1);
}
const data = (await res.json()) as { accessToken: string };
return data.accessToken;
}
async function fetchAllSecrets(
token: string,
config: Config
): Promise<Array<{ secretKey: string; secretValue: string }>> {
const url = `${API_BASE}/v3/secrets/raw?environment=${encodeURIComponent(config.env)}&workspaceId=${encodeURIComponent(config.projectId)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const body = await res.text();
fail(`Failed to fetch secrets (${res.status}): ${body}`);
process.exit(1);
}
const data = (await res.json()) as {
secrets: Array<{ secretKey: string; secretValue: string }>;
};
return data.secrets;
}
async function fetchOneSecret(
token: string,
config: Config,
key: string
): Promise<string> {
const url = `${API_BASE}/v3/secrets/raw/${encodeURIComponent(key)}?environment=${encodeURIComponent(config.env)}&workspaceId=${encodeURIComponent(config.projectId)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
if (res.status === 404) {
fail(`Secret "${key}" not found`);
} else {
const body = await res.text();
fail(`Failed to fetch secret (${res.status}): ${body}`);
}
process.exit(1);
}
const data = (await res.json()) as {
secret: { secretKey: string; secretValue: string };
};
return data.secret.secretValue;
}
async function upsertSecret(
token: string,
config: Config,
key: string,
value: string
): Promise<void> {
// Try PATCH first (update existing)
const patchUrl = `${API_BASE}/v3/secrets/raw/${encodeURIComponent(key)}`;
const body = JSON.stringify({
workspaceId: config.projectId,
environment: config.env,
secretValue: value,
type: "shared",
});
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
};
const patchRes = await fetch(patchUrl, {
method: "PATCH",
headers,
body,
});
if (patchRes.ok) return;
// If PATCH fails (404 = doesn't exist), try POST to create
if (patchRes.status === 400 || patchRes.status === 404) {
const postRes = await fetch(`${API_BASE}/v3/secrets/raw/${encodeURIComponent(key)}`, {
method: "POST",
headers,
body,
});
if (!postRes.ok) {
const errBody = await postRes.text();
fail(`Failed to create secret (${postRes.status}): ${errBody}`);
process.exit(1);
}
return;
}
const errBody = await patchRes.text();
fail(`Failed to update secret (${patchRes.status}): ${errBody}`);
process.exit(1);
}
// ─── Commands ──────────────────────────────────────────────────────────────────
async function cmdGet(key: string, fresh: boolean) {
const config = await loadConfig();
const cache = await loadCache();
// Check cache first (unless --fresh)
if (!fresh && cache.secrets[key] && isCacheValid(cache.secrets[key], config.ttlMs)) {
const val = cache.secrets[key].value;
console.log(val);
console.error(`${c.cyan}${c.reset} ${c.dim}(from cache)${c.reset}`);
return;
}
// Fetch from Infisical
const token = await authenticate(config);
const value = await fetchOneSecret(token, config, key);
// Update cache
cache.secrets[key] = { value, updatedAt: Date.now() };
cache.ttlMs = config.ttlMs;
await saveCache(cache);
console.log(value);
console.error(`${c.cyan}${c.reset} ${c.dim}(from Infisical)${c.reset}`);
}
async function cmdSet(key: string, value: string) {
const config = await loadConfig();
const token = await authenticate(config);
await upsertSecret(token, config, key, value);
// Update cache
const cache = await loadCache();
cache.secrets[key] = { value, updatedAt: Date.now() };
cache.ttlMs = config.ttlMs;
await saveCache(cache);
ok(`Set ${c.bold}${key}${c.reset}`);
}
async function cmdList(showValues: boolean) {
const config = await loadConfig();
const token = await authenticate(config);
const secrets = await fetchAllSecrets(token, config);
if (secrets.length === 0) {
warn("No secrets found");
return;
}
// Sort by key
secrets.sort((a, b) => a.secretKey.localeCompare(b.secretKey));
console.log(
`\n${c.bold}${c.cyan}Secrets${c.reset} ${c.dim}(${secrets.length} total, env: ${config.env})${c.reset}\n`
);
const maxKeyLen = Math.max(...secrets.map((s) => s.secretKey.length));
for (const s of secrets) {
const key = s.secretKey.padEnd(maxKeyLen);
if (showValues) {
console.log(` ${c.green}${key}${c.reset} ${c.dim}=${c.reset} ${s.secretValue}`);
} else {
console.log(` ${c.green}${key}${c.reset}`);
}
}
console.log();
}
async function cmdSync() {
const config = await loadConfig();
const token = await authenticate(config);
const secrets = await fetchAllSecrets(token, config);
const cache: Cache = {
secrets: {},
lastSync: Date.now(),
ttlMs: config.ttlMs,
};
for (const s of secrets) {
cache.secrets[s.secretKey] = {
value: s.secretValue,
updatedAt: Date.now(),
};
}
await saveCache(cache);
ok(`Synced ${c.bold}${secrets.length}${c.reset} secrets to cache`);
}
async function cmdExec(args: string[]) {
if (args.length === 0) {
fail("Usage: secret exec -- <command> [args...]");
process.exit(1);
}
const config = await loadConfig();
const cache = await loadCache();
// Check if cache is fresh enough, otherwise sync
const hasValidCache =
Object.keys(cache.secrets).length > 0 &&
Date.now() - cache.lastSync < config.ttlMs;
let secrets: Record<string, string>;
if (hasValidCache) {
secrets = Object.fromEntries(
Object.entries(cache.secrets).map(([k, v]) => [k, v.value])
);
info(`${c.dim}Using cached secrets${c.reset}`);
} else {
// Fetch fresh
const token = await authenticate(config);
const fetched = await fetchAllSecrets(token, config);
secrets = Object.fromEntries(
fetched.map((s) => [s.secretKey, s.secretValue])
);
// Update cache
const newCache: Cache = {
secrets: {},
lastSync: Date.now(),
ttlMs: config.ttlMs,
};
for (const s of fetched) {
newCache.secrets[s.secretKey] = {
value: s.secretValue,
updatedAt: Date.now(),
};
}
await saveCache(newCache);
info(`${c.dim}Synced ${fetched.length} secrets${c.reset}`);
}
// Merge secrets into env and exec
const env = { ...Bun.env, ...secrets };
const proc = Bun.spawn(args, {
env,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
});
const exitCode = await proc.exited;
process.exit(exitCode);
}
// ─── Main ──────────────────────────────────────────────────────────────────────
function printUsage() {
console.log(`
${c.bold}${c.cyan}@openclaw/secret${c.reset} — Infisical secret manager with local caching
${c.bold}Usage:${c.reset}
secret get <KEY> [--fresh] Get a secret value (cache-first)
secret set <KEY> <VALUE> Set/update a secret
secret list [--show] List all secret keys
secret sync Sync all secrets to local cache
secret exec -- <cmd> [args] Run command with secrets as env vars
${c.bold}Config:${c.reset}
${c.dim}~/.config/oc-secret/config.json${c.reset}
${c.dim}or INFISICAL_CLIENT_ID / INFISICAL_CLIENT_SECRET env vars${c.reset}
`);
}
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
printUsage();
process.exit(0);
}
const command = args[0];
try {
switch (command) {
case "get": {
const key = args[1];
if (!key) {
fail("Usage: secret get <KEY> [--fresh]");
process.exit(1);
}
const fresh = args.includes("--fresh");
await cmdGet(key, fresh);
break;
}
case "set": {
const key = args[1];
const value = args[2];
if (!key || value === undefined) {
fail("Usage: secret set <KEY> <VALUE>");
process.exit(1);
}
await cmdSet(key, value);
break;
}
case "list": {
const showValues = args.includes("--show");
await cmdList(showValues);
break;
}
case "sync": {
await cmdSync();
break;
}
case "exec": {
// Find "--" separator
const dashIdx = args.indexOf("--");
const cmdArgs = dashIdx >= 0 ? args.slice(dashIdx + 1) : args.slice(1);
await cmdExec(cmdArgs);
break;
}
case "help":
case "--help":
case "-h": {
printUsage();
break;
}
default:
fail(`Unknown command: ${command}`);
printUsage();
process.exit(1);
}
} catch (err: unknown) {
if (err instanceof TypeError && String(err).includes("fetch")) {
fail("Network error — check your internet connection");
process.exit(1);
}
if (err instanceof Error) {
fail(err.message);
} else {
fail(String(err));
}
process.exit(1);
}
}
main();