Xiaonuo d6e78fc5a9 chore: drop legacy Python CLI, keep Node.js version as cli/cfg.js
The Python CLI (cli/cfg) was outdated. The actively maintained
version is the Node.js CLI published as @shazhou/config on npm.

Signed-off-by: Xiaonuo <xiaonuo@shazhou.work>
2026-04-21 10:40:14 +08:00

475 lines
14 KiB
JavaScript
Executable File

#!/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);
}