diff --git a/cli/cfg-node b/cli/cfg-node new file mode 100755 index 0000000..31f9282 --- /dev/null +++ b/cli/cfg-node @@ -0,0 +1,474 @@ +#!/usr/bin/env node + +// src/cli.ts +import { readFileSync, writeFileSync, mkdirSync } from "fs"; +import { join } from "path"; +import { homedir } from "os"; +var CONFIG_DIR = join(homedir(), ".config", "cfg"); +var CONFIG_FILE = join(CONFIG_DIR, "config.json"); +var CACHE_FILE = join(CONFIG_DIR, "cache.json"); +var DEFAULT_ENDPOINT = "https://config.shazhou.work"; +var ONE_DAY = 24 * 60 * 60 * 1000; +var TWO_HOURS = 2 * 60 * 60 * 1000; +function loadConfig() { + try { + return JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); + } catch { + return {}; + } +} +function saveConfig(cfg) { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + ` +`); +} +function loadCache() { + try { + return JSON.parse(readFileSync(CACHE_FILE, "utf-8")); + } catch { + return null; + } +} +function saveCache(cache) { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2) + ` +`); +} +function getToken() { + const cfg = loadConfig(); + const token = process.env.CFG_TOKEN || cfg.token; + if (!token) { + console.error("No token configured. Run: cfg token "); + process.exit(1); + } + return token; +} +function getEndpoint() { + const cfg = loadConfig(); + return cfg.endpoint || process.env.CFG_ENDPOINT || DEFAULT_ENDPOINT; +} +async function api(method, path, body) { + const url = `${getEndpoint()}${path}`; + const headers = { + Authorization: `Bearer ${getToken()}`, + "Content-Type": "application/json" + }; + let res; + try { + res = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined + }); + } catch (err) { + console.error(`Error: ${err?.cause?.message || err?.message || "network error"}`); + process.exit(1); + } + if (res.status === 204) + return { data: null, status: 204 }; + const text = await res.text(); + let data; + try { + data = JSON.parse(text); + } catch { + data = text; + } + if (!res.ok) { + const msg = data?.error || text; + console.error(`Error: ${msg}`); + process.exit(1); + } + return { data, status: res.status }; +} +async function trySyncRemote() { + const url = `${getEndpoint()}/config`; + const headers = { + Authorization: `Bearer ${getToken()}`, + "Content-Type": "application/json" + }; + try { + const res = await fetch(url, { method: "GET", headers }); + if (!res.ok) + return false; + const data = await res.json(); + if (!data?.secrets) + return false; + const now = new Date().toISOString(); + const cache = { + agent_id: data.agent_id, + secrets: data.secrets, + synced_at: now, + attempted_at: now + }; + saveCache(cache); + return true; + } catch { + const cache = loadCache(); + if (cache) { + cache.attempted_at = new Date().toISOString(); + saveCache(cache); + } + return false; + } +} +function shouldAutoSync(cache) { + if (!cache) + return true; + const now = Date.now(); + const syncedAt = new Date(cache.synced_at).getTime(); + const attemptedAt = new Date(cache.attempted_at || cache.synced_at).getTime(); + if (syncedAt >= attemptedAt && now - syncedAt < ONE_DAY) { + return false; + } + if (now - attemptedAt < TWO_HOURS) { + return false; + } + return true; +} +async function cmdSync() { + const { data } = await api("GET", "/config"); + if (!data?.secrets) { + console.error("No secrets found"); + process.exit(1); + } + const now = new Date().toISOString(); + const cache = { + agent_id: data.agent_id, + secrets: data.secrets, + synced_at: now, + attempted_at: now + }; + saveCache(cache); + const count = Object.keys(cache.secrets).length; + console.log(`✓ Synced ${count} keys (agent: ${cache.agent_id})`); +} +async function cmdEnv() { + let cache = loadCache(); + if (shouldAutoSync(cache)) { + const ok = await trySyncRemote(); + if (ok) { + cache = loadCache(); + } else if (!cache) { + console.error("No local cache and sync failed. Run: cfg sync"); + process.exit(1); + } + } + for (const [key, entry] of Object.entries(cache.secrets).sort(([a], [b]) => a.localeCompare(b))) { + if (entry.env === false) continue; + const escaped = entry.value.replace(/'/g, "'\\''"); + console.log(`export ${key}='${escaped}'`); + } +} +async function cmdGet(key, remote) { + if (remote) { + const { data } = await api("GET", `/config/${encodeURIComponent(key)}`); + console.log(data.value); + return; + } + let cache = loadCache(); + if (shouldAutoSync(cache)) { + const ok = await trySyncRemote(); + if (ok) + cache = loadCache(); + else if (!cache) { + console.error("No local cache and sync failed. Run: cfg sync"); + process.exit(1); + } + } + if (!cache) { + console.error("No local cache. Run: cfg sync"); + process.exit(1); + } + const entry = cache.secrets[key]; + if (!entry) { + console.error(`Error: ${key} not found in cache. Try: cfg sync`); + process.exit(1); + } + console.log(entry.value); +} +async function cmdSet(args) { + const shared = args.includes("--shared"); + const noEnv = args.includes("--no-env"); + const isSecret = args.includes("--secret"); + const filtered = args.filter((a) => !["--shared", "--no-env", "--secret"].includes(a)); + if (filtered.length < 2) { + console.error("Usage: cfg set [--shared] [--no-env] [--secret] "); + process.exit(1); + } + const [key, ...rest] = filtered; + const value = rest.join(" "); + const scope = shared ? "shared" : "personal"; + const body = { value, env: !noEnv, secret: isSecret }; + await api("PUT", `/config/${encodeURIComponent(key)}?scope=${scope}`, body); + const flags = []; + if (noEnv) flags.push("no-env"); + if (isSecret) flags.push("secret"); + const flagStr = flags.length ? ` [${flags.join(", ")}]` : ""; + console.log(`✓ ${key} (${scope})${flagStr}`); + const cache = loadCache(); + if (cache) { + cache.secrets[key] = { value, scope, env: !noEnv, secret: isSecret, updated_at: new Date().toISOString() }; + saveCache(cache); + } +} +async function cmdUnset(args) { + const shared = args.includes("--shared"); + const filtered = args.filter((a) => a !== "--shared"); + if (filtered.length < 1) { + console.error("Usage: cfg unset [--shared] "); + process.exit(1); + } + const key = filtered[0]; + const scope = shared ? "shared" : "personal"; + await api("DELETE", `/config/${encodeURIComponent(key)}?scope=${scope}`); + console.log(`✓ Deleted ${key} (${scope})`); + const cache = loadCache(); + if (cache && cache.secrets[key]) { + delete cache.secrets[key]; + saveCache(cache); + } +} +async function cmdList() { + let cache = loadCache(); + if (shouldAutoSync(cache)) { + const ok = await trySyncRemote(); + if (ok) + cache = loadCache(); + else if (!cache) { + console.error("No local cache and sync failed. Run: cfg sync"); + process.exit(1); + } + } + if (!cache) { + console.error("No local cache. Run: cfg sync"); + process.exit(1); + } + if (!cache.secrets || Object.keys(cache.secrets).length === 0) { + console.log("No keys found"); + return; + } + const keys = Object.keys(cache.secrets).sort(); + const maxLen = Math.max(...keys.map((k) => k.length)); + for (const key of keys) { + const entry = cache.secrets[key]; + const scope = entry.scope === "personal" ? "\x1B[32mpersonal\x1B[0m" : "\x1B[34mshared\x1B[0m "; + const flags = []; + if (entry.env === false) flags.push("\x1B[33mno-env\x1B[0m"); + if (entry.secret) flags.push("\x1B[31msecret\x1B[0m"); + const flagStr = flags.length ? " " + flags.join(" ") : ""; + console.log(` ${key.padEnd(maxLen + 2)}${scope}${flagStr}`); + } + console.log(` +Total: ${keys.length} keys (agent: ${cache.agent_id})`); + console.log(`Last sync: ${cache.synced_at}`); +} +function cmdToken(token) { + const cfg = loadConfig(); + cfg.token = token; + saveConfig(cfg); + console.log("✓ Token saved"); +} +async function cmdAdminAddUser(args) { + const role = args.includes("--admin") ? "admin" : "agent"; + const filtered = args.filter((a) => a !== "--admin"); + if (!filtered[0]) { + console.error("Usage: cfg admin add [--admin]"); + process.exit(1); + } + const { data } = await api("POST", "/admin/token", { agent_id: filtered[0], role }); + console.log(`✓ Created ${data.role} token for ${data.agent_id}`); + console.log(` Token: ${data.token}`); +} +async function cmdAdminRemoveUser(args) { + if (!args[0]) { + console.error("Usage: cfg admin remove "); + process.exit(1); + } + const { data } = await api("DELETE", `/admin/token/${encodeURIComponent(args[0])}`); + console.log(`✓ Revoked ${data.tokens_revoked} token(s) for ${data.agent_id}`); +} +async function cmdAdminListAgents() { + const { data } = await api("GET", "/admin/agents"); + if (!data.agents?.length) { + console.log("No agents found"); + return; + } + console.log("Agents:"); + for (const agent of data.agents) { + console.log(` ${agent}`); + } +} +async function cmdAdminRefreshToken(args) { + if (!args[0]) { + console.error("Usage: cfg admin refresh "); + process.exit(1); + } + const agentId = args[0]; + await api("DELETE", `/admin/token/${encodeURIComponent(agentId)}`); + const { data } = await api("POST", "/admin/token", { agent_id: agentId }); + console.log(`✓ Refreshed token for ${data.agent_id}`); + console.log(` Token: ${data.token}`); +} +async function cmdAdminInspect(args) { + if (!args[0]) { + console.error("Usage: cfg admin inspect "); + process.exit(1); + } + const { data } = await api("GET", `/admin/agent/${encodeURIComponent(args[0])}`); + if (!data.secrets || Object.keys(data.secrets).length === 0) { + console.log(`No keys for ${data.agent_id}`); + return; + } + const keys = Object.keys(data.secrets).sort(); + const maxLen = Math.max(...keys.map((k) => k.length)); + for (const key of keys) { + const entry = data.secrets[key]; + const scope = entry.scope === "personal" ? "\x1B[32mpersonal\x1B[0m" : "\x1B[34mshared\x1B[0m "; + console.log(` ${key.padEnd(maxLen + 2)}${scope}`); + } + console.log(` +Total: ${keys.length} keys (agent: ${data.agent_id})`); +} +async function cmdFlags(args) { + const shared = args.includes("--shared"); + const filtered = args.filter((a) => !["--shared"].includes(a)); + if (filtered.length < 1) { + console.error("Usage: cfg flags [--shared] [--env|--no-env] [--secret|--no-secret]"); + process.exit(1); + } + const key = filtered[0]; + const flagArgs = filtered.slice(1); + const body = {}; + if (flagArgs.includes("--env")) body.env = true; + if (flagArgs.includes("--no-env")) body.env = false; + if (flagArgs.includes("--secret")) body.secret = true; + if (flagArgs.includes("--no-secret")) body.secret = false; + if (Object.keys(body).length === 0) { + // Just show current flags + const { data } = await api("GET", `/config/${encodeURIComponent(key)}`); + console.log(`${key}: env=${data.env ?? true}, secret=${data.secret ?? false} (${data.scope})`); + return; + } + const scope = shared ? "shared" : "personal"; + const { data } = await api("PATCH", `/config/${encodeURIComponent(key)}?scope=${scope}`, body); + console.log(`✓ ${key} flags updated: env=${data.env}, secret=${data.secret}`); + // Update cache + const cache = loadCache(); + if (cache && cache.secrets[key]) { + if (body.env !== undefined) cache.secrets[key].env = body.env; + if (body.secret !== undefined) cache.secrets[key].secret = body.secret; + saveCache(cache); + } +} +function showHelp() { + console.log(`cfg — config.shazhou.work CLI + +Usage: + cfg sync Fetch all config to local cache + cfg env Output export statements (from cache, auto-syncs if stale) + cfg get Read a key (from cache) + cfg get --remote Read a key (from server) + cfg set Write to personal scope + cfg set --shared Write to shared scope (admin only) + cfg set --no-env Mark as non-env (won't export via cfg env) + cfg set --secret Mark as secret (sensitive, UI masked) + cfg flags Show flags for a key + cfg flags --no-env Set no-env flag + cfg flags --secret Set secret flag + cfg unset Delete from personal scope + cfg unset --shared Delete from shared scope (admin only) + cfg list List all keys with scope (from cache) + cfg token Save auth token + +Admin: + cfg admin agents List all agents + cfg admin add [--admin] Create agent token (default role: agent) + cfg admin remove Revoke all tokens for an agent + cfg admin refresh Revoke + recreate token + cfg admin inspect View an agent's resolved config + +Auto-sync: + cfg env auto-syncs when cache is stale (>1 day since last success, + or last sync failed and >2 hours since last attempt). + Failed syncs fall back to stale cache silently. + +Environment: + CFG_TOKEN Auth token (overrides saved token) + CFG_ENDPOINT API endpoint (default: ${DEFAULT_ENDPOINT}) + +Shell setup: + eval $(cfg env) Add to .profile / .bashrc / .zshrc`); +} +var [cmd, ...args] = process.argv.slice(2); +switch (cmd) { + case "sync": + await cmdSync(); + break; + case "env": + await cmdEnv(); + break; + case "get": { + const remote = args.includes("--remote"); + const filtered = args.filter((a) => a !== "--remote"); + if (!filtered[0]) { + console.error("Usage: cfg get [--remote] "); + process.exit(1); + } + await cmdGet(filtered[0], remote); + break; + } + case "set": + await cmdSet(args); + break; + case "unset": + await cmdUnset(args); + break; + case "flags": + await cmdFlags(args); + break; + case "list": + await cmdList(); + break; + case "token": + if (!args[0]) { + console.error("Usage: cfg token "); + process.exit(1); + } + cmdToken(args[0]); + break; + case "admin": { + const [sub, ...subArgs] = args; + switch (sub) { + case "agents": + await cmdAdminListAgents(); + break; + case "add": + await cmdAdminAddUser(subArgs); + break; + case "remove": + await cmdAdminRemoveUser(subArgs); + break; + case "refresh": + await cmdAdminRefreshToken(subArgs); + break; + case "inspect": + await cmdAdminInspect(subArgs); + break; + default: + console.error(`Unknown admin command: ${sub}`); + showHelp(); + process.exit(1); + } + break; + } + case "help": + case "--help": + case "-h": + case undefined: + showHelp(); + break; + default: + console.error(`Unknown command: ${cmd}`); + showHelp(); + process.exit(1); +} diff --git a/worker/src/index.ts b/worker/src/index.ts index 3f8c1e1..3c7a8f6 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -10,6 +10,8 @@ interface AuthInfo { interface KVEntry { value: string; updated_at: string; + env?: boolean; // whether to export as env var (default: true) + secret?: boolean; // whether value is sensitive (default: false) } async function sha256(text: string): Promise { @@ -42,12 +44,26 @@ async function handleGet(key: string, auth: AuthInfo, kv: KVNamespace): Promise< // Try personal first const personal = await kv.get(`personal:${auth.agent_id}:${key}`, "json"); if (personal) { - return json({ key, value: personal.value, scope: "personal", updated_at: personal.updated_at }); + return json({ + key, + value: personal.value, + scope: "personal", + env: personal.env ?? true, + secret: personal.secret ?? false, + updated_at: personal.updated_at, + }); } // Fallback to shared const shared = await kv.get(`shared:${key}`, "json"); if (shared) { - return json({ key, value: shared.value, scope: "shared", updated_at: shared.updated_at }); + return json({ + key, + value: shared.value, + scope: "shared", + env: shared.env ?? true, + secret: shared.secret ?? false, + updated_at: shared.updated_at, + }); } return err("not found", 404); } @@ -60,10 +76,19 @@ async function handlePut( kv: KVNamespace, request: Request ): Promise { - const body = await request.json<{ value: string }>(); + const body = await request.json<{ + value: string; + env?: boolean; + secret?: boolean; + }>(); if (!body?.value && body?.value !== "") return err("missing 'value' in body"); - const entry: KVEntry = { value: body.value, updated_at: new Date().toISOString() }; + const entry: KVEntry = { + value: body.value, + updated_at: new Date().toISOString(), + env: body.env ?? true, + secret: body.secret ?? false, + }; if (scope === "shared") { if (auth.role !== "admin") return err("admin required for shared scope", 403); @@ -71,7 +96,34 @@ async function handlePut( } else { await kv.put(`personal:${auth.agent_id}:${key}`, JSON.stringify(entry)); } - return json({ key, scope, status: "ok" }); + return json({ key, scope, env: entry.env, secret: entry.secret, status: "ok" }); +} + +// PATCH /config/:key — update flags only (env, secret) without changing value +async function handlePatch( + key: string, + scope: string, + auth: AuthInfo, + kv: KVNamespace, + request: Request +): Promise { + const body = await request.json<{ env?: boolean; secret?: boolean }>(); + + const kvKey = scope === "shared" ? `shared:${key}` : `personal:${auth.agent_id}:${key}`; + + if (scope === "shared" && auth.role !== "admin") { + return err("admin required for shared scope", 403); + } + + const existing = await kv.get(kvKey, "json"); + if (!existing) return err("not found", 404); + + if (body.env !== undefined) existing.env = body.env; + if (body.secret !== undefined) existing.secret = body.secret; + existing.updated_at = new Date().toISOString(); + + await kv.put(kvKey, JSON.stringify(existing)); + return json({ key, scope, env: existing.env, secret: existing.secret, status: "ok" }); } // DELETE /config/:key @@ -103,14 +155,28 @@ async function handleList(scope: string | null, auth: AuthInfo, kv: KVNamespace) // POST /config/sync — return all resolved key-values (personal > shared) async function handleSync(auth: AuthInfo, kv: KVNamespace): Promise { - const resolved: Record = {}; + const resolved: Record = {}; // Load shared first const sharedList = await kv.list({ prefix: "shared:" }); for (const k of sharedList.keys) { const key = k.name.replace("shared:", ""); const entry = await kv.get(k.name, "json"); - if (entry) resolved[key] = { value: entry.value, scope: "shared", updated_at: entry.updated_at }; + if (entry) { + resolved[key] = { + value: entry.value, + scope: "shared", + env: entry.env ?? true, + secret: entry.secret ?? false, + updated_at: entry.updated_at, + }; + } } // Personal overrides @@ -118,12 +184,105 @@ async function handleSync(auth: AuthInfo, kv: KVNamespace): Promise { for (const k of personalList.keys) { const key = k.name.replace(`personal:${auth.agent_id}:`, ""); const entry = await kv.get(k.name, "json"); - if (entry) resolved[key] = { value: entry.value, scope: "personal", updated_at: entry.updated_at }; + if (entry) { + resolved[key] = { + value: entry.value, + scope: "personal", + env: entry.env ?? true, + secret: entry.secret ?? false, + updated_at: entry.updated_at, + }; + } } return json({ agent_id: auth.agent_id, secrets: resolved }); } +// ── Admin endpoints ──────────────────────────────────────────────────── + +async function handleAdminCreateToken(auth: AuthInfo, kv: KVNamespace, request: Request): Promise { + if (auth.role !== "admin") return err("admin required", 403); + const body = await request.json<{ agent_id: string; role?: string }>(); + if (!body?.agent_id) return err("missing agent_id"); + + const role = body.role === "admin" ? "admin" : "agent"; + const token = crypto.randomUUID() + "-" + crypto.randomUUID(); + const hash = await sha256(token); + await kv.put(`auth:${hash}`, JSON.stringify({ agent_id: body.agent_id, role })); + + // Track token in agent's token list + const tokenListKey = `agent_tokens:${body.agent_id}`; + const existing = await kv.get(tokenListKey, "json") || []; + existing.push(hash); + await kv.put(tokenListKey, JSON.stringify(existing)); + + return json({ agent_id: body.agent_id, role, token }); +} + +async function handleAdminRevokeTokens(auth: AuthInfo, kv: KVNamespace, agentId: string): Promise { + if (auth.role !== "admin") return err("admin required", 403); + const tokenListKey = `agent_tokens:${agentId}`; + const hashes = await kv.get(tokenListKey, "json") || []; + for (const hash of hashes) { + await kv.delete(`auth:${hash}`); + } + await kv.delete(tokenListKey); + return json({ agent_id: agentId, tokens_revoked: hashes.length }); +} + +async function handleAdminListAgents(auth: AuthInfo, kv: KVNamespace): Promise { + if (auth.role !== "admin") return err("admin required", 403); + const list = await kv.list({ prefix: "agent_tokens:" }); + const agents = list.keys.map((k) => k.name.replace("agent_tokens:", "")); + return json({ agents }); +} + +async function handleAdminInspect(auth: AuthInfo, kv: KVNamespace, agentId: string): Promise { + if (auth.role !== "admin") return err("admin required", 403); + + const resolved: Record = {}; + + const sharedList = await kv.list({ prefix: "shared:" }); + for (const k of sharedList.keys) { + const key = k.name.replace("shared:", ""); + const entry = await kv.get(k.name, "json"); + if (entry) { + resolved[key] = { + value: entry.value, + scope: "shared", + env: entry.env ?? true, + secret: entry.secret ?? false, + updated_at: entry.updated_at, + }; + } + } + + const personalList = await kv.list({ prefix: `personal:${agentId}:` }); + for (const k of personalList.keys) { + const key = k.name.replace(`personal:${agentId}:`, ""); + const entry = await kv.get(k.name, "json"); + if (entry) { + resolved[key] = { + value: entry.value, + scope: "personal", + env: entry.env ?? true, + secret: entry.secret ?? false, + updated_at: entry.updated_at, + }; + } + } + + return json({ agent_id: agentId, secrets: resolved }); +} + +// ── Router ───────────────────────────────────────────────────────────── + export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); @@ -149,6 +308,22 @@ export default { return handleList(url.searchParams.get("scope"), auth, env.CONFIG_KV); } + // Admin routes + if (path === "/admin/token" && method === "POST") { + return handleAdminCreateToken(auth, env.CONFIG_KV, request); + } + if (path.startsWith("/admin/token/") && method === "DELETE") { + const agentId = decodeURIComponent(path.replace("/admin/token/", "")); + return handleAdminRevokeTokens(auth, env.CONFIG_KV, agentId); + } + if (path === "/admin/agents" && method === "GET") { + return handleAdminListAgents(auth, env.CONFIG_KV); + } + if (path.startsWith("/admin/agent/") && method === "GET") { + const agentId = decodeURIComponent(path.replace("/admin/agent/", "")); + return handleAdminInspect(auth, env.CONFIG_KV, agentId); + } + // /config/:key const keyMatch = path.match(/^\/config\/(.+)$/); if (!keyMatch) return err("not found", 404); @@ -159,6 +334,8 @@ export default { return handleGet(key, auth, env.CONFIG_KV); case "PUT": return handlePut(key, scope, auth, env.CONFIG_KV, request); + case "PATCH": + return handlePatch(key, scope, auth, env.CONFIG_KV, request); case "DELETE": return handleDelete(key, scope, auth, env.CONFIG_KV); default: