commit 271de19d053bda1cfc6232d0bc33527bf184356e Author: 小橘 Date: Mon Apr 6 23:06:45 2026 +0000 feat: initial release — Infisical secret CLI with local caching 小橘 🍊(NEKO Team) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..23e3024 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# @oc-forge/secret + +🔐 Infisical secret manager CLI with local caching. + +Manage secrets from [Infisical](https://infisical.com/) with a simple CLI. Caches secrets locally (24h TTL) to minimize API calls. + +## Install + +```bash +# Requires Bun runtime +npm install -g @oc-forge/secret +``` + +## Setup + +Create a config file at `~/.config/oc-secret/config.json`: + +```json +{ + "clientId": "", + "clientSecret": "", + "projectId": "", + "env": "dev" +} +``` + +Or use environment variables: + +```bash +export INFISICAL_CLIENT_ID=xxx +export INFISICAL_CLIENT_SECRET=xxx +export INFISICAL_PROJECT_ID=xxx +export INFISICAL_ENV=dev # optional, defaults to "dev" +``` + +## Usage + +```bash +# Get a secret (cache-first) +secret get MY_API_KEY + +# Get a secret (skip cache) +secret get MY_API_KEY --fresh + +# Set/update a secret +secret set MY_API_KEY "new-value" + +# List all secret keys +secret list + +# List with values +secret list --show + +# Sync all secrets to local cache +secret sync + +# Run a command with all secrets as env vars +secret exec -- node server.js +``` + +## How it works + +1. **Cache-first**: `secret get` checks local cache (`~/.config/oc-secret/cache.json`) before hitting the API +2. **24h TTL**: Cache entries expire after 24 hours (configurable via `ttlMs` in config) +3. **Upsert**: `secret set` creates or updates the secret on Infisical and updates local cache +4. **Exec**: `secret exec` injects all secrets as environment variables into a child process + +## Output + +- Secret values go to **stdout** (clean, no decoration) +- Status messages go to **stderr** (won't pollute `$(secret get KEY)`) + +```bash +# Safe to use in shell substitution +TOKEN=$(secret get MY_TOKEN) +``` + +## Security + +- Cache file is chmod 600 (owner-only read/write) +- Credentials never leave your machine +- Universal Auth (machine identity) — no user login required + +## License + +MIT — 小橘爪作 🐾 diff --git a/package.json b/package.json new file mode 100644 index 0000000..12b8812 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "@oc-forge/secret", + "version": "0.1.0", + "description": "Infisical secret manager CLI with local caching", + "type": "module", + "bin": { + "secret": "./secret.ts" + }, + "files": [ + "secret.ts" + ], + "keywords": [ + "infisical", + "secret", + "cli", + "cache" + ], + "author": "小橘 🍊 ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/oc-xiaoju/secret.git" + }, + "engines": { + "bun": ">=1.0.0" + } +} diff --git a/secret.ts b/secret.ts new file mode 100644 index 0000000..6c8f290 --- /dev/null +++ b/secret.ts @@ -0,0 +1,508 @@ +#!/usr/bin/env bun +// @openclaw/secret - Infisical secret management CLI with local caching +// Usage: secret [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; + lastSync: number; + ttlMs: number; +} + +// ─── Config ──────────────────────────────────────────────────────────────────── + +async function loadConfig(): Promise { + let fileConfig: Partial = {}; + + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + // 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 -- [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; + + 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 [--fresh] Get a secret value (cache-first) + secret set Set/update a secret + secret list [--show] List all secret keys + secret sync Sync all secrets to local cache + secret exec -- [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 [--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 "); + 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();