@oc-forge/secret 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +115 -0
  2. package/package.json +16 -0
  3. package/secret.ts +508 -0
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # @oc-forge/secret
2
+
3
+ **Infisical secret management CLI with local caching — for OpenClaw teams**
4
+
5
+ 一个用于管理 [Infisical](https://infisical.com) secrets 的命令行工具,带本地缓存,专为 OpenClaw 团队设计。
6
+
7
+ ---
8
+
9
+ ## 安装 / Installation
10
+
11
+ 需要 [Bun](https://bun.sh) runtime(v1.0+)。
12
+
13
+ ```bash
14
+ npm i -g @oc-forge/secret
15
+ # 或
16
+ bun add -g @oc-forge/secret
17
+ ```
18
+
19
+ 安装后即可使用 `secret` 命令。
20
+
21
+ ---
22
+
23
+ ## 配置 / Configuration
24
+
25
+ ### 方式一:配置文件(推荐)
26
+
27
+ 创建 `~/.config/openclaw-fleet/config.json`:
28
+
29
+ ```json
30
+ {
31
+ "clientId": "your-machine-identity-client-id",
32
+ "clientSecret": "your-machine-identity-client-secret",
33
+ "projectId": "your-infisical-project-id",
34
+ "env": "dev"
35
+ }
36
+ ```
37
+
38
+ ### 方式二:环境变量
39
+
40
+ ```bash
41
+ export INFISICAL_CLIENT_ID="your-client-id"
42
+ export INFISICAL_CLIENT_SECRET="your-client-secret"
43
+ export INFISICAL_PROJECT_ID="your-project-id"
44
+ export INFISICAL_ENV="dev" # 可选,默认 dev
45
+ ```
46
+
47
+ > ⚠️ 不要将实际凭证提交到代码仓库。
48
+
49
+ ---
50
+
51
+ ## 命令 / Commands
52
+
53
+ ```bash
54
+ # 获取 secret(优先读取缓存)
55
+ secret get <KEY>
56
+ secret get <KEY> --fresh # 强制从 Infisical 拉取最新值
57
+
58
+ # 设置 / 更新 secret
59
+ secret set <KEY> <VALUE>
60
+
61
+ # 列出所有 secret keys
62
+ secret list
63
+ secret list --show # 同时显示值
64
+
65
+ # 全量同步到本地缓存
66
+ secret sync
67
+
68
+ # 注入所有 secrets 为环境变量并运行命令
69
+ secret exec -- <command> [args...]
70
+
71
+ # 帮助
72
+ secret --help
73
+ ```
74
+
75
+ ### 示例 / Examples
76
+
77
+ ```bash
78
+ # 获取数据库密码
79
+ secret get DATABASE_URL
80
+
81
+ # 设置 API key
82
+ secret set OPENAI_API_KEY sk-...
83
+
84
+ # 注入 secrets 运行应用
85
+ secret exec -- node server.js
86
+
87
+ # 同步所有 secrets 到缓存
88
+ secret sync
89
+ ```
90
+
91
+ ---
92
+
93
+ ## 缓存 / Caching
94
+
95
+ - 缓存文件:`~/.config/openclaw-fleet/cache.json`(权限 600)
96
+ - 默认 TTL:24 小时
97
+ - `secret get` 自动使用缓存;`secret exec` 在缓存过期时自动同步
98
+ - 可在 config.json 中设置 `ttlMs` 自定义缓存时长
99
+
100
+ ---
101
+
102
+ ## 认证方式 / Authentication
103
+
104
+ 使用 Infisical [Universal Auth (Machine Identity)](https://infisical.com/docs/documentation/platform/identities/universal-auth) 认证。
105
+
106
+ 配置步骤:
107
+ 1. 在 Infisical 控制台创建 Machine Identity
108
+ 2. 授予项目访问权限
109
+ 3. 将 `clientId` 和 `clientSecret` 填入配置文件
110
+
111
+ ---
112
+
113
+ ## License
114
+
115
+ MIT © 小橘 🍊
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@oc-forge/secret",
3
+ "version": "0.1.0",
4
+ "description": "Infisical secret management CLI with local caching — for OpenClaw teams",
5
+ "type": "module",
6
+ "bin": {
7
+ "secret": "./secret.ts"
8
+ },
9
+ "files": ["secret.ts", "README.md", "LICENSE"],
10
+ "keywords": ["infisical", "secret", "cli", "openclaw", "oc-forge"],
11
+ "author": "小橘 🍊 <xiaoju@shazhou.work>",
12
+ "license": "MIT",
13
+ "engines": {
14
+ "bun": ">=1.0.0"
15
+ }
16
+ }
package/secret.ts ADDED
@@ -0,0 +1,508 @@
1
+ #!/usr/bin/env bun
2
+ // @openclaw/secret - Infisical secret management CLI with local caching
3
+ // Usage: secret <command> [args]
4
+
5
+ // ─── Colors ────────────────────────────────────────────────────────────────────
6
+
7
+ const c = {
8
+ reset: "\x1b[0m",
9
+ bold: "\x1b[1m",
10
+ dim: "\x1b[2m",
11
+ red: "\x1b[31m",
12
+ green: "\x1b[32m",
13
+ yellow: "\x1b[33m",
14
+ blue: "\x1b[34m",
15
+ magenta: "\x1b[35m",
16
+ cyan: "\x1b[36m",
17
+ gray: "\x1b[90m",
18
+ };
19
+
20
+ function ok(msg: string) {
21
+ console.log(`${c.green}✓${c.reset} ${msg}`);
22
+ }
23
+ function info(msg: string) {
24
+ console.log(`${c.cyan}ℹ${c.reset} ${msg}`);
25
+ }
26
+ function warn(msg: string) {
27
+ console.log(`${c.yellow}⚠${c.reset} ${msg}`);
28
+ }
29
+ function fail(msg: string) {
30
+ console.error(`${c.red}✗${c.reset} ${msg}`);
31
+ }
32
+
33
+ // ─── Paths ─────────────────────────────────────────────────────────────────────
34
+
35
+ const HOME = Bun.env.HOME || "~";
36
+ const CONFIG_DIR = `${HOME}/.config/openclaw-fleet`;
37
+ const CONFIG_PATH = `${CONFIG_DIR}/config.json`;
38
+ const CACHE_PATH = `${CONFIG_DIR}/cache.json`;
39
+
40
+ // ─── Types ─────────────────────────────────────────────────────────────────────
41
+
42
+ interface Config {
43
+ clientId: string;
44
+ clientSecret: string;
45
+ projectId: string;
46
+ env: string;
47
+ ttlMs: number;
48
+ }
49
+
50
+ interface CacheEntry {
51
+ value: string;
52
+ updatedAt: number;
53
+ }
54
+
55
+ interface Cache {
56
+ secrets: Record<string, CacheEntry>;
57
+ lastSync: number;
58
+ ttlMs: number;
59
+ }
60
+
61
+ // ─── Config ────────────────────────────────────────────────────────────────────
62
+
63
+ async function loadConfig(): Promise<Config> {
64
+ let fileConfig: Partial<Config> = {};
65
+
66
+ const configFile = Bun.file(CONFIG_PATH);
67
+ if (await configFile.exists()) {
68
+ try {
69
+ fileConfig = await configFile.json();
70
+ } catch {
71
+ warn("config.json is malformed, using env vars only");
72
+ }
73
+ }
74
+
75
+ const clientId =
76
+ Bun.env.INFISICAL_CLIENT_ID || fileConfig.clientId || "";
77
+ const clientSecret =
78
+ Bun.env.INFISICAL_CLIENT_SECRET || fileConfig.clientSecret || "";
79
+ const projectId =
80
+ Bun.env.INFISICAL_PROJECT_ID ||
81
+ fileConfig.projectId ||
82
+ "";
83
+ const env = Bun.env.INFISICAL_ENV || fileConfig.env || "dev";
84
+ const ttlMs = fileConfig.ttlMs || 86400000; // 24h
85
+
86
+ if (!clientId || !clientSecret) {
87
+ fail(
88
+ "Missing Infisical credentials. Set INFISICAL_CLIENT_ID/INFISICAL_CLIENT_SECRET or add them to config.json"
89
+ );
90
+ process.exit(1);
91
+ }
92
+
93
+ if (!projectId) {
94
+ fail(
95
+ "Missing Infisical project ID. Set INFISICAL_PROJECT_ID or add projectId to config.json"
96
+ );
97
+ process.exit(1);
98
+ }
99
+
100
+ return { clientId, clientSecret, projectId, env, ttlMs };
101
+ }
102
+
103
+ // ─── Cache ─────────────────────────────────────────────────────────────────────
104
+
105
+ async function loadCache(): Promise<Cache> {
106
+ const cacheFile = Bun.file(CACHE_PATH);
107
+ if (await cacheFile.exists()) {
108
+ try {
109
+ const data = await cacheFile.json();
110
+ if (data && typeof data.secrets === "object") {
111
+ return data as Cache;
112
+ }
113
+ } catch {
114
+ warn("Cache file corrupted, starting fresh");
115
+ }
116
+ }
117
+ return { secrets: {}, lastSync: 0, ttlMs: 86400000 };
118
+ }
119
+
120
+ async function saveCache(cache: Cache): Promise<void> {
121
+ await Bun.write(CACHE_PATH, JSON.stringify(cache, null, 2));
122
+ // Set file permissions to 600
123
+ const proc = Bun.spawn(["chmod", "600", CACHE_PATH]);
124
+ await proc.exited;
125
+ }
126
+
127
+ function isCacheValid(entry: CacheEntry, ttlMs: number): boolean {
128
+ return Date.now() - entry.updatedAt < ttlMs;
129
+ }
130
+
131
+ // ─── Infisical API ─────────────────────────────────────────────────────────────
132
+
133
+ const API_BASE = "https://app.infisical.com/api";
134
+
135
+ async function authenticate(config: Config): Promise<string> {
136
+ const res = await fetch(`${API_BASE}/v1/auth/universal-auth/login`, {
137
+ method: "POST",
138
+ headers: { "Content-Type": "application/json" },
139
+ body: JSON.stringify({
140
+ clientId: config.clientId,
141
+ clientSecret: config.clientSecret,
142
+ }),
143
+ });
144
+
145
+ if (!res.ok) {
146
+ const body = await res.text();
147
+ if (res.status === 401 || res.status === 403) {
148
+ fail("Authentication failed — check your clientId/clientSecret");
149
+ } else {
150
+ fail(`Auth request failed (${res.status}): ${body}`);
151
+ }
152
+ process.exit(1);
153
+ }
154
+
155
+ const data = (await res.json()) as { accessToken: string };
156
+ return data.accessToken;
157
+ }
158
+
159
+ async function fetchAllSecrets(
160
+ token: string,
161
+ config: Config
162
+ ): Promise<Array<{ secretKey: string; secretValue: string }>> {
163
+ const url = `${API_BASE}/v3/secrets/raw?environment=${encodeURIComponent(config.env)}&workspaceId=${encodeURIComponent(config.projectId)}`;
164
+
165
+ const res = await fetch(url, {
166
+ headers: { Authorization: `Bearer ${token}` },
167
+ });
168
+
169
+ if (!res.ok) {
170
+ const body = await res.text();
171
+ fail(`Failed to fetch secrets (${res.status}): ${body}`);
172
+ process.exit(1);
173
+ }
174
+
175
+ const data = (await res.json()) as {
176
+ secrets: Array<{ secretKey: string; secretValue: string }>;
177
+ };
178
+ return data.secrets;
179
+ }
180
+
181
+ async function fetchOneSecret(
182
+ token: string,
183
+ config: Config,
184
+ key: string
185
+ ): Promise<string> {
186
+ const url = `${API_BASE}/v3/secrets/raw/${encodeURIComponent(key)}?environment=${encodeURIComponent(config.env)}&workspaceId=${encodeURIComponent(config.projectId)}`;
187
+
188
+ const res = await fetch(url, {
189
+ headers: { Authorization: `Bearer ${token}` },
190
+ });
191
+
192
+ if (!res.ok) {
193
+ if (res.status === 404) {
194
+ fail(`Secret "${key}" not found`);
195
+ } else {
196
+ const body = await res.text();
197
+ fail(`Failed to fetch secret (${res.status}): ${body}`);
198
+ }
199
+ process.exit(1);
200
+ }
201
+
202
+ const data = (await res.json()) as {
203
+ secret: { secretKey: string; secretValue: string };
204
+ };
205
+ return data.secret.secretValue;
206
+ }
207
+
208
+ async function upsertSecret(
209
+ token: string,
210
+ config: Config,
211
+ key: string,
212
+ value: string
213
+ ): Promise<void> {
214
+ // Try PATCH first (update existing)
215
+ const patchUrl = `${API_BASE}/v3/secrets/raw/${encodeURIComponent(key)}`;
216
+ const body = JSON.stringify({
217
+ workspaceId: config.projectId,
218
+ environment: config.env,
219
+ secretValue: value,
220
+ type: "shared",
221
+ });
222
+ const headers = {
223
+ Authorization: `Bearer ${token}`,
224
+ "Content-Type": "application/json",
225
+ };
226
+
227
+ const patchRes = await fetch(patchUrl, {
228
+ method: "PATCH",
229
+ headers,
230
+ body,
231
+ });
232
+
233
+ if (patchRes.ok) return;
234
+
235
+ // If PATCH fails (404 = doesn't exist), try POST to create
236
+ if (patchRes.status === 400 || patchRes.status === 404) {
237
+ const postRes = await fetch(`${API_BASE}/v3/secrets/raw/${encodeURIComponent(key)}`, {
238
+ method: "POST",
239
+ headers,
240
+ body,
241
+ });
242
+
243
+ if (!postRes.ok) {
244
+ const errBody = await postRes.text();
245
+ fail(`Failed to create secret (${postRes.status}): ${errBody}`);
246
+ process.exit(1);
247
+ }
248
+ return;
249
+ }
250
+
251
+ const errBody = await patchRes.text();
252
+ fail(`Failed to update secret (${patchRes.status}): ${errBody}`);
253
+ process.exit(1);
254
+ }
255
+
256
+ // ─── Commands ──────────────────────────────────────────────────────────────────
257
+
258
+ async function cmdGet(key: string, fresh: boolean) {
259
+ const config = await loadConfig();
260
+ const cache = await loadCache();
261
+
262
+ // Check cache first (unless --fresh)
263
+ if (!fresh && cache.secrets[key] && isCacheValid(cache.secrets[key], config.ttlMs)) {
264
+ const val = cache.secrets[key].value;
265
+ console.log(val);
266
+ info(`${c.dim}(from cache)${c.reset}`);
267
+ return;
268
+ }
269
+
270
+ // Fetch from Infisical
271
+ const token = await authenticate(config);
272
+ const value = await fetchOneSecret(token, config, key);
273
+
274
+ // Update cache
275
+ cache.secrets[key] = { value, updatedAt: Date.now() };
276
+ cache.ttlMs = config.ttlMs;
277
+ await saveCache(cache);
278
+
279
+ console.log(value);
280
+ info(`${c.dim}(from Infisical)${c.reset}`);
281
+ }
282
+
283
+ async function cmdSet(key: string, value: string) {
284
+ const config = await loadConfig();
285
+ const token = await authenticate(config);
286
+
287
+ await upsertSecret(token, config, key, value);
288
+
289
+ // Update cache
290
+ const cache = await loadCache();
291
+ cache.secrets[key] = { value, updatedAt: Date.now() };
292
+ cache.ttlMs = config.ttlMs;
293
+ await saveCache(cache);
294
+
295
+ ok(`Set ${c.bold}${key}${c.reset} ✓`);
296
+ }
297
+
298
+ async function cmdList(showValues: boolean) {
299
+ const config = await loadConfig();
300
+ const token = await authenticate(config);
301
+ const secrets = await fetchAllSecrets(token, config);
302
+
303
+ if (secrets.length === 0) {
304
+ warn("No secrets found");
305
+ return;
306
+ }
307
+
308
+ // Sort by key
309
+ secrets.sort((a, b) => a.secretKey.localeCompare(b.secretKey));
310
+
311
+ console.log(
312
+ `\n${c.bold}${c.cyan}Secrets${c.reset} ${c.dim}(${secrets.length} total, env: ${config.env})${c.reset}\n`
313
+ );
314
+
315
+ const maxKeyLen = Math.max(...secrets.map((s) => s.secretKey.length));
316
+
317
+ for (const s of secrets) {
318
+ const key = s.secretKey.padEnd(maxKeyLen);
319
+ if (showValues) {
320
+ console.log(` ${c.green}${key}${c.reset} ${c.dim}=${c.reset} ${s.secretValue}`);
321
+ } else {
322
+ console.log(` ${c.green}${key}${c.reset}`);
323
+ }
324
+ }
325
+ console.log();
326
+ }
327
+
328
+ async function cmdSync() {
329
+ const config = await loadConfig();
330
+ const token = await authenticate(config);
331
+ const secrets = await fetchAllSecrets(token, config);
332
+
333
+ const cache: Cache = {
334
+ secrets: {},
335
+ lastSync: Date.now(),
336
+ ttlMs: config.ttlMs,
337
+ };
338
+
339
+ for (const s of secrets) {
340
+ cache.secrets[s.secretKey] = {
341
+ value: s.secretValue,
342
+ updatedAt: Date.now(),
343
+ };
344
+ }
345
+
346
+ await saveCache(cache);
347
+ ok(`Synced ${c.bold}${secrets.length}${c.reset} secrets to cache`);
348
+ }
349
+
350
+ async function cmdExec(args: string[]) {
351
+ if (args.length === 0) {
352
+ fail("Usage: secret exec -- <command> [args...]");
353
+ process.exit(1);
354
+ }
355
+
356
+ const config = await loadConfig();
357
+ const cache = await loadCache();
358
+
359
+ // Check if cache is fresh enough, otherwise sync
360
+ const hasValidCache =
361
+ Object.keys(cache.secrets).length > 0 &&
362
+ Date.now() - cache.lastSync < config.ttlMs;
363
+
364
+ let secrets: Record<string, string>;
365
+
366
+ if (hasValidCache) {
367
+ secrets = Object.fromEntries(
368
+ Object.entries(cache.secrets).map(([k, v]) => [k, v.value])
369
+ );
370
+ info(`${c.dim}Using cached secrets${c.reset}`);
371
+ } else {
372
+ // Fetch fresh
373
+ const token = await authenticate(config);
374
+ const fetched = await fetchAllSecrets(token, config);
375
+
376
+ secrets = Object.fromEntries(
377
+ fetched.map((s) => [s.secretKey, s.secretValue])
378
+ );
379
+
380
+ // Update cache
381
+ const newCache: Cache = {
382
+ secrets: {},
383
+ lastSync: Date.now(),
384
+ ttlMs: config.ttlMs,
385
+ };
386
+ for (const s of fetched) {
387
+ newCache.secrets[s.secretKey] = {
388
+ value: s.secretValue,
389
+ updatedAt: Date.now(),
390
+ };
391
+ }
392
+ await saveCache(newCache);
393
+ info(`${c.dim}Synced ${fetched.length} secrets${c.reset}`);
394
+ }
395
+
396
+ // Merge secrets into env and exec
397
+ const env = { ...Bun.env, ...secrets };
398
+
399
+ const proc = Bun.spawn(args, {
400
+ env,
401
+ stdout: "inherit",
402
+ stderr: "inherit",
403
+ stdin: "inherit",
404
+ });
405
+
406
+ const exitCode = await proc.exited;
407
+ process.exit(exitCode);
408
+ }
409
+
410
+ // ─── Main ──────────────────────────────────────────────────────────────────────
411
+
412
+ function printUsage() {
413
+ console.log(`
414
+ ${c.bold}${c.cyan}@openclaw/secret${c.reset} — Infisical secret manager with local caching
415
+
416
+ ${c.bold}Usage:${c.reset}
417
+ secret get <KEY> [--fresh] Get a secret value (cache-first)
418
+ secret set <KEY> <VALUE> Set/update a secret
419
+ secret list [--show] List all secret keys
420
+ secret sync Sync all secrets to local cache
421
+ secret exec -- <cmd> [args] Run command with secrets as env vars
422
+
423
+ ${c.bold}Config:${c.reset}
424
+ ${c.dim}~/.config/openclaw-fleet/config.json${c.reset}
425
+ ${c.dim}or INFISICAL_CLIENT_ID / INFISICAL_CLIENT_SECRET env vars${c.reset}
426
+ `);
427
+ }
428
+
429
+ async function main() {
430
+ const args = process.argv.slice(2);
431
+
432
+ if (args.length === 0) {
433
+ printUsage();
434
+ process.exit(0);
435
+ }
436
+
437
+ const command = args[0];
438
+
439
+ try {
440
+ switch (command) {
441
+ case "get": {
442
+ const key = args[1];
443
+ if (!key) {
444
+ fail("Usage: secret get <KEY> [--fresh]");
445
+ process.exit(1);
446
+ }
447
+ const fresh = args.includes("--fresh");
448
+ await cmdGet(key, fresh);
449
+ break;
450
+ }
451
+
452
+ case "set": {
453
+ const key = args[1];
454
+ const value = args[2];
455
+ if (!key || value === undefined) {
456
+ fail("Usage: secret set <KEY> <VALUE>");
457
+ process.exit(1);
458
+ }
459
+ await cmdSet(key, value);
460
+ break;
461
+ }
462
+
463
+ case "list": {
464
+ const showValues = args.includes("--show");
465
+ await cmdList(showValues);
466
+ break;
467
+ }
468
+
469
+ case "sync": {
470
+ await cmdSync();
471
+ break;
472
+ }
473
+
474
+ case "exec": {
475
+ // Find "--" separator
476
+ const dashIdx = args.indexOf("--");
477
+ const cmdArgs = dashIdx >= 0 ? args.slice(dashIdx + 1) : args.slice(1);
478
+ await cmdExec(cmdArgs);
479
+ break;
480
+ }
481
+
482
+ case "help":
483
+ case "--help":
484
+ case "-h": {
485
+ printUsage();
486
+ break;
487
+ }
488
+
489
+ default:
490
+ fail(`Unknown command: ${command}`);
491
+ printUsage();
492
+ process.exit(1);
493
+ }
494
+ } catch (err: unknown) {
495
+ if (err instanceof TypeError && String(err).includes("fetch")) {
496
+ fail("Network error — check your internet connection");
497
+ process.exit(1);
498
+ }
499
+ if (err instanceof Error) {
500
+ fail(err.message);
501
+ } else {
502
+ fail(String(err));
503
+ }
504
+ process.exit(1);
505
+ }
506
+ }
507
+
508
+ main();