Merge pull request 'feat: env/secret flags for config entries' (#2) from feat/entry-flags into main
This commit is contained in:
commit
211533346b
166
cli/cfg
166
cli/cfg
@ -1,166 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""cfg — CLI client for the config service.
|
||||
|
||||
Usage:
|
||||
cfg get <KEY> Read a key (personal > shared)
|
||||
cfg set <KEY> <VALUE> Write to personal scope
|
||||
cfg set --shared <KEY> <VALUE> Write to shared scope (admin)
|
||||
cfg list [--scope shared|personal] List keys
|
||||
cfg sync Sync all to local cache
|
||||
cfg delete <KEY> [--shared] Delete a key
|
||||
cfg token <TOKEN> Save auth token
|
||||
|
||||
Config: ~/.config/cfg/config.json
|
||||
Cache: ~/.config/cfg/cache.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_DIR = Path.home() / ".config" / "cfg"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
CACHE_FILE = CONFIG_DIR / "cache.json"
|
||||
|
||||
DEFAULT_ENDPOINT = "https://config-service.oc-fleet.workers.dev"
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
if CONFIG_FILE.exists():
|
||||
return json.loads(CONFIG_FILE.read_text())
|
||||
return {}
|
||||
|
||||
|
||||
def save_config(cfg: dict):
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
|
||||
|
||||
|
||||
def get_token() -> str:
|
||||
cfg = load_config()
|
||||
token = cfg.get("token") or os.environ.get("CFG_TOKEN")
|
||||
if not token:
|
||||
print("No token configured. Run: cfg token <TOKEN>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return token
|
||||
|
||||
|
||||
def get_endpoint() -> str:
|
||||
cfg = load_config()
|
||||
return cfg.get("endpoint", DEFAULT_ENDPOINT)
|
||||
|
||||
|
||||
def api(method: str, path: str, body: dict | None = None) -> dict:
|
||||
url = f"{get_endpoint()}{path}"
|
||||
data = json.dumps(body).encode() if body else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Authorization", f"Bearer {get_token()}")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
result = json.loads(e.read())
|
||||
print(f"Error: {result.get('error', 'unknown')}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_get(args: list[str]):
|
||||
if not args:
|
||||
print("Usage: cfg get <KEY>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
result = api("GET", f"/config/{args[0]}")
|
||||
print(result["value"])
|
||||
|
||||
|
||||
def cmd_set(args: list[str]):
|
||||
shared = "--shared" in args
|
||||
args = [a for a in args if a != "--shared"]
|
||||
if len(args) < 2:
|
||||
print("Usage: cfg set [--shared] <KEY> <VALUE>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
key, value = args[0], args[1]
|
||||
scope = "shared" if shared else "personal"
|
||||
api("PUT", f"/config/{key}?scope={scope}", {"value": value})
|
||||
print(f"✓ Set {key} ({scope})")
|
||||
|
||||
|
||||
def cmd_delete(args: list[str]):
|
||||
shared = "--shared" in args
|
||||
args = [a for a in args if a != "--shared"]
|
||||
if not args:
|
||||
print("Usage: cfg delete <KEY> [--shared]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
scope = "shared" if shared else "personal"
|
||||
api("DELETE", f"/config/{args[0]}?scope={scope}")
|
||||
print(f"✓ Deleted {args[0]} ({scope})")
|
||||
|
||||
|
||||
def cmd_list(args: list[str]):
|
||||
scope = None
|
||||
if "--scope" in args:
|
||||
idx = args.index("--scope")
|
||||
if idx + 1 < len(args):
|
||||
scope = args[idx + 1]
|
||||
path = "/config" + (f"?scope={scope}" if scope else "")
|
||||
result = api("GET", path)
|
||||
if "keys" in result:
|
||||
for k in sorted(result["keys"]):
|
||||
print(f" {k}")
|
||||
elif "secrets" in result:
|
||||
for k, v in sorted(result["secrets"].items()):
|
||||
print(f" {k:<40} ({v['scope']})")
|
||||
|
||||
|
||||
def cmd_sync(args: list[str]):
|
||||
result = api("POST", "/config/sync")
|
||||
# Save to cache
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache = {
|
||||
"secrets": {k: {"value": v["value"], "updatedAt": v["updated_at"]} for k, v in result["secrets"].items()},
|
||||
"lastSync": result.get("agent_id", ""),
|
||||
}
|
||||
CACHE_FILE.write_text(json.dumps(cache, indent=2))
|
||||
print(f"✓ Synced {len(result['secrets'])} keys")
|
||||
|
||||
|
||||
def cmd_token(args: list[str]):
|
||||
if not args:
|
||||
print("Usage: cfg token <TOKEN>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
cfg = load_config()
|
||||
cfg["token"] = args[0]
|
||||
save_config(cfg)
|
||||
print("✓ Token saved")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(0)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
args = sys.argv[2:]
|
||||
|
||||
commands = {
|
||||
"get": cmd_get,
|
||||
"set": cmd_set,
|
||||
"delete": cmd_delete,
|
||||
"list": cmd_list,
|
||||
"sync": cmd_sync,
|
||||
"token": cmd_token,
|
||||
}
|
||||
|
||||
if cmd not in commands:
|
||||
print(f"Unknown command: {cmd}", file=sys.stderr)
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
commands[cmd](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
474
cli/cfg.js
Executable file
474
cli/cfg.js
Executable file
@ -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 <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] <KEY> <VALUE>");
|
||||
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] <KEY>");
|
||||
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 <AGENT_ID> [--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 <AGENT_ID>");
|
||||
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 <AGENT_ID>");
|
||||
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 <AGENT_ID>");
|
||||
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] <KEY> [--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 <KEY> Read a key (from cache)
|
||||
cfg get --remote <KEY> Read a key (from server)
|
||||
cfg set <KEY> <VALUE> Write to personal scope
|
||||
cfg set --shared <KEY> <VALUE> Write to shared scope (admin only)
|
||||
cfg set --no-env <KEY> <VALUE> Mark as non-env (won't export via cfg env)
|
||||
cfg set --secret <KEY> <VALUE> Mark as secret (sensitive, UI masked)
|
||||
cfg flags <KEY> Show flags for a key
|
||||
cfg flags <KEY> --no-env Set no-env flag
|
||||
cfg flags <KEY> --secret Set secret flag
|
||||
cfg unset <KEY> Delete from personal scope
|
||||
cfg unset --shared <KEY> Delete from shared scope (admin only)
|
||||
cfg list List all keys with scope (from cache)
|
||||
cfg token <TOKEN> Save auth token
|
||||
|
||||
Admin:
|
||||
cfg admin agents List all agents
|
||||
cfg admin add <ID> [--admin] Create agent token (default role: agent)
|
||||
cfg admin remove <ID> Revoke all tokens for an agent
|
||||
cfg admin refresh <ID> Revoke + recreate token
|
||||
cfg admin inspect <ID> 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] <KEY>");
|
||||
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 <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);
|
||||
}
|
||||
@ -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<string> {
|
||||
@ -42,12 +44,26 @@ async function handleGet(key: string, auth: AuthInfo, kv: KVNamespace): Promise<
|
||||
// Try personal first
|
||||
const personal = await kv.get<KVEntry>(`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<KVEntry>(`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<Response> {
|
||||
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<Response> {
|
||||
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<KVEntry>(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<Response> {
|
||||
const resolved: Record<string, { value: string; scope: string; updated_at: string }> = {};
|
||||
const resolved: Record<string, {
|
||||
value: string;
|
||||
scope: string;
|
||||
env: boolean;
|
||||
secret: boolean;
|
||||
updated_at: string;
|
||||
}> = {};
|
||||
|
||||
// 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<KVEntry>(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<Response> {
|
||||
for (const k of personalList.keys) {
|
||||
const key = k.name.replace(`personal:${auth.agent_id}:`, "");
|
||||
const entry = await kv.get<KVEntry>(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<Response> {
|
||||
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<string[]>(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<Response> {
|
||||
if (auth.role !== "admin") return err("admin required", 403);
|
||||
const tokenListKey = `agent_tokens:${agentId}`;
|
||||
const hashes = await kv.get<string[]>(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<Response> {
|
||||
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<Response> {
|
||||
if (auth.role !== "admin") return err("admin required", 403);
|
||||
|
||||
const resolved: Record<string, {
|
||||
value: string;
|
||||
scope: string;
|
||||
env: boolean;
|
||||
secret: boolean;
|
||||
updated_at: string;
|
||||
}> = {};
|
||||
|
||||
const sharedList = await kv.list({ prefix: "shared:" });
|
||||
for (const k of sharedList.keys) {
|
||||
const key = k.name.replace("shared:", "");
|
||||
const entry = await kv.get<KVEntry>(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<KVEntry>(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<Response> {
|
||||
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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user