feat: initial release — Infisical secret CLI with local caching

小橘 🍊(NEKO Team)
This commit is contained in:
小橘 2026-04-06 23:06:45 +00:00
commit 271de19d05
4 changed files with 623 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
*.log

86
README.md Normal file
View File

@ -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": "<your-infisical-client-id>",
"clientSecret": "<your-infisical-client-secret>",
"projectId": "<your-infisical-project-id>",
"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 — 小橘爪作 🐾

27
package.json Normal file
View File

@ -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": "小橘 🍊 <xiaoju@shazhou.work>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/oc-xiaoju/secret.git"
},
"engines": {
"bun": ">=1.0.0"
}
}

508
secret.ts Normal file
View File

@ -0,0 +1,508 @@
#!/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();