refactor: bun monorepo, @shazhou/cfg CLI (TS), cleanup old cli/scripts
- Restructured as bun monorepo with packages/cfg and packages/worker - CLI rewritten in TypeScript with modular architecture - Published as @shazhou/cfg@1.0.0 (replaces @shazhou/config) - Deprecated @shazhou/config on npm - Removed legacy Python scripts and old cli-npm package
This commit is contained in:
parent
211533346b
commit
117e334a07
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
|||||||
.wrangler/
|
.wrangler/
|
||||||
|
.npmrc
|
||||||
|
|||||||
5
package.json
Normal file
5
package.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "config-service",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": ["packages/*"]
|
||||||
|
}
|
||||||
1
packages/cfg/.gitignore
vendored
Normal file
1
packages/cfg/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
dist/
|
||||||
17
packages/cfg/package.json
Normal file
17
packages/cfg/package.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@shazhou/cfg",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"cfg": "./dist/cli.js"
|
||||||
|
},
|
||||||
|
"files": ["dist"],
|
||||||
|
"scripts": {
|
||||||
|
"build": "bun build src/cli.ts --outfile dist/cli.js --target node --minify && { echo '#!/usr/bin/env node'; cat dist/cli.js; } > dist/cli.tmp && mv dist/cli.tmp dist/cli.js",
|
||||||
|
"prepublishOnly": "bun run build"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
70
packages/cfg/src/api.ts
Normal file
70
packages/cfg/src/api.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { getToken, getEndpoint, loadCache, saveCache } from "./config.js";
|
||||||
|
|
||||||
|
export async function api(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown
|
||||||
|
): Promise<{ data: any; status: number }> {
|
||||||
|
const url = `${getEndpoint()}${path}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${getToken()}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
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: any;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function trySyncRemote(): Promise<boolean> {
|
||||||
|
const url = `${getEndpoint()}/config`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
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() as any;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
packages/cfg/src/cli.ts
Normal file
91
packages/cfg/src/cli.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
cmdSync,
|
||||||
|
cmdEnv,
|
||||||
|
cmdGet,
|
||||||
|
cmdSet,
|
||||||
|
cmdUnset,
|
||||||
|
cmdFlags,
|
||||||
|
cmdList,
|
||||||
|
cmdToken,
|
||||||
|
cmdAdminAddUser,
|
||||||
|
cmdAdminRemoveUser,
|
||||||
|
cmdAdminListAgents,
|
||||||
|
cmdAdminRefreshToken,
|
||||||
|
cmdAdminInspect,
|
||||||
|
showHelp,
|
||||||
|
} from "./commands.js";
|
||||||
|
|
||||||
|
const [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);
|
||||||
|
}
|
||||||
374
cli/cfg.js → packages/cfg/src/commands.ts
Executable file → Normal file
374
cli/cfg.js → packages/cfg/src/commands.ts
Executable file → Normal file
@ -1,119 +1,17 @@
|
|||||||
#!/usr/bin/env node
|
import {
|
||||||
|
loadConfig,
|
||||||
|
saveConfig,
|
||||||
|
loadCache,
|
||||||
|
saveCache,
|
||||||
|
ONE_DAY,
|
||||||
|
TWO_HOURS,
|
||||||
|
DEFAULT_ENDPOINT,
|
||||||
|
type Cache,
|
||||||
|
} from "./config.js";
|
||||||
|
import { api, trySyncRemote } from "./api.js";
|
||||||
|
|
||||||
// src/cli.ts
|
export function shouldAutoSync(cache: Cache | null): boolean {
|
||||||
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
if (!cache) return true;
|
||||||
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 now = Date.now();
|
||||||
const syncedAt = new Date(cache.synced_at).getTime();
|
const syncedAt = new Date(cache.synced_at).getTime();
|
||||||
const attemptedAt = new Date(cache.attempted_at || cache.synced_at).getTime();
|
const attemptedAt = new Date(cache.attempted_at || cache.synced_at).getTime();
|
||||||
@ -125,7 +23,8 @@ function shouldAutoSync(cache) {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
async function cmdSync() {
|
|
||||||
|
export async function cmdSync(): Promise<void> {
|
||||||
const { data } = await api("GET", "/config");
|
const { data } = await api("GET", "/config");
|
||||||
if (!data?.secrets) {
|
if (!data?.secrets) {
|
||||||
console.error("No secrets found");
|
console.error("No secrets found");
|
||||||
@ -136,13 +35,14 @@ async function cmdSync() {
|
|||||||
agent_id: data.agent_id,
|
agent_id: data.agent_id,
|
||||||
secrets: data.secrets,
|
secrets: data.secrets,
|
||||||
synced_at: now,
|
synced_at: now,
|
||||||
attempted_at: now
|
attempted_at: now,
|
||||||
};
|
};
|
||||||
saveCache(cache);
|
saveCache(cache);
|
||||||
const count = Object.keys(cache.secrets).length;
|
const count = Object.keys(cache.secrets).length;
|
||||||
console.log(`✓ Synced ${count} keys (agent: ${cache.agent_id})`);
|
console.log(`✓ Synced ${count} keys (agent: ${cache.agent_id})`);
|
||||||
}
|
}
|
||||||
async function cmdEnv() {
|
|
||||||
|
export async function cmdEnv(): Promise<void> {
|
||||||
let cache = loadCache();
|
let cache = loadCache();
|
||||||
if (shouldAutoSync(cache)) {
|
if (shouldAutoSync(cache)) {
|
||||||
const ok = await trySyncRemote();
|
const ok = await trySyncRemote();
|
||||||
@ -153,13 +53,16 @@ async function cmdEnv() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [key, entry] of Object.entries(cache.secrets).sort(([a], [b]) => a.localeCompare(b))) {
|
for (const [key, entry] of Object.entries(cache!.secrets).sort(([a], [b]) =>
|
||||||
|
a.localeCompare(b)
|
||||||
|
)) {
|
||||||
if (entry.env === false) continue;
|
if (entry.env === false) continue;
|
||||||
const escaped = entry.value.replace(/'/g, "'\\''");
|
const escaped = entry.value.replace(/'/g, "'\\''");
|
||||||
console.log(`export ${key}='${escaped}'`);
|
console.log(`export ${key}='${escaped}'`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function cmdGet(key, remote) {
|
|
||||||
|
export async function cmdGet(key: string, remote: boolean): Promise<void> {
|
||||||
if (remote) {
|
if (remote) {
|
||||||
const { data } = await api("GET", `/config/${encodeURIComponent(key)}`);
|
const { data } = await api("GET", `/config/${encodeURIComponent(key)}`);
|
||||||
console.log(data.value);
|
console.log(data.value);
|
||||||
@ -168,8 +71,7 @@ async function cmdGet(key, remote) {
|
|||||||
let cache = loadCache();
|
let cache = loadCache();
|
||||||
if (shouldAutoSync(cache)) {
|
if (shouldAutoSync(cache)) {
|
||||||
const ok = await trySyncRemote();
|
const ok = await trySyncRemote();
|
||||||
if (ok)
|
if (ok) cache = loadCache();
|
||||||
cache = loadCache();
|
|
||||||
else if (!cache) {
|
else if (!cache) {
|
||||||
console.error("No local cache and sync failed. Run: cfg sync");
|
console.error("No local cache and sync failed. Run: cfg sync");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@ -186,13 +88,18 @@ async function cmdGet(key, remote) {
|
|||||||
}
|
}
|
||||||
console.log(entry.value);
|
console.log(entry.value);
|
||||||
}
|
}
|
||||||
async function cmdSet(args) {
|
|
||||||
|
export async function cmdSet(args: string[]): Promise<void> {
|
||||||
const shared = args.includes("--shared");
|
const shared = args.includes("--shared");
|
||||||
const noEnv = args.includes("--no-env");
|
const noEnv = args.includes("--no-env");
|
||||||
const isSecret = args.includes("--secret");
|
const isSecret = args.includes("--secret");
|
||||||
const filtered = args.filter((a) => !["--shared", "--no-env", "--secret"].includes(a));
|
const filtered = args.filter(
|
||||||
|
(a) => !["--shared", "--no-env", "--secret"].includes(a)
|
||||||
|
);
|
||||||
if (filtered.length < 2) {
|
if (filtered.length < 2) {
|
||||||
console.error("Usage: cfg set [--shared] [--no-env] [--secret] <KEY> <VALUE>");
|
console.error(
|
||||||
|
"Usage: cfg set [--shared] [--no-env] [--secret] <KEY> <VALUE>"
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const [key, ...rest] = filtered;
|
const [key, ...rest] = filtered;
|
||||||
@ -200,18 +107,25 @@ async function cmdSet(args) {
|
|||||||
const scope = shared ? "shared" : "personal";
|
const scope = shared ? "shared" : "personal";
|
||||||
const body = { value, env: !noEnv, secret: isSecret };
|
const body = { value, env: !noEnv, secret: isSecret };
|
||||||
await api("PUT", `/config/${encodeURIComponent(key)}?scope=${scope}`, body);
|
await api("PUT", `/config/${encodeURIComponent(key)}?scope=${scope}`, body);
|
||||||
const flags = [];
|
const flags: string[] = [];
|
||||||
if (noEnv) flags.push("no-env");
|
if (noEnv) flags.push("no-env");
|
||||||
if (isSecret) flags.push("secret");
|
if (isSecret) flags.push("secret");
|
||||||
const flagStr = flags.length ? ` [${flags.join(", ")}]` : "";
|
const flagStr = flags.length ? ` [${flags.join(", ")}]` : "";
|
||||||
console.log(`✓ ${key} (${scope})${flagStr}`);
|
console.log(`✓ ${key} (${scope})${flagStr}`);
|
||||||
const cache = loadCache();
|
const cache = loadCache();
|
||||||
if (cache) {
|
if (cache) {
|
||||||
cache.secrets[key] = { value, scope, env: !noEnv, secret: isSecret, updated_at: new Date().toISOString() };
|
cache.secrets[key] = {
|
||||||
|
value,
|
||||||
|
scope,
|
||||||
|
env: !noEnv,
|
||||||
|
secret: isSecret,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
saveCache(cache);
|
saveCache(cache);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function cmdUnset(args) {
|
|
||||||
|
export async function cmdUnset(args: string[]): Promise<void> {
|
||||||
const shared = args.includes("--shared");
|
const shared = args.includes("--shared");
|
||||||
const filtered = args.filter((a) => a !== "--shared");
|
const filtered = args.filter((a) => a !== "--shared");
|
||||||
if (filtered.length < 1) {
|
if (filtered.length < 1) {
|
||||||
@ -228,12 +142,12 @@ async function cmdUnset(args) {
|
|||||||
saveCache(cache);
|
saveCache(cache);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function cmdList() {
|
|
||||||
|
export async function cmdList(): Promise<void> {
|
||||||
let cache = loadCache();
|
let cache = loadCache();
|
||||||
if (shouldAutoSync(cache)) {
|
if (shouldAutoSync(cache)) {
|
||||||
const ok = await trySyncRemote();
|
const ok = await trySyncRemote();
|
||||||
if (ok)
|
if (ok) cache = loadCache();
|
||||||
cache = loadCache();
|
|
||||||
else if (!cache) {
|
else if (!cache) {
|
||||||
console.error("No local cache and sync failed. Run: cfg sync");
|
console.error("No local cache and sync failed. Run: cfg sync");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@ -251,43 +165,95 @@ async function cmdList() {
|
|||||||
const maxLen = Math.max(...keys.map((k) => k.length));
|
const maxLen = Math.max(...keys.map((k) => k.length));
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const entry = cache.secrets[key];
|
const entry = cache.secrets[key];
|
||||||
const scope = entry.scope === "personal" ? "\x1B[32mpersonal\x1B[0m" : "\x1B[34mshared\x1B[0m ";
|
const scope =
|
||||||
const flags = [];
|
entry.scope === "personal"
|
||||||
|
? "\x1B[32mpersonal\x1B[0m"
|
||||||
|
: "\x1B[34mshared\x1B[0m ";
|
||||||
|
const flags: string[] = [];
|
||||||
if (entry.env === false) flags.push("\x1B[33mno-env\x1B[0m");
|
if (entry.env === false) flags.push("\x1B[33mno-env\x1B[0m");
|
||||||
if (entry.secret) flags.push("\x1B[31msecret\x1B[0m");
|
if (entry.secret) flags.push("\x1B[31msecret\x1B[0m");
|
||||||
const flagStr = flags.length ? " " + flags.join(" ") : "";
|
const flagStr = flags.length ? " " + flags.join(" ") : "";
|
||||||
console.log(` ${key.padEnd(maxLen + 2)}${scope}${flagStr}`);
|
console.log(` ${key.padEnd(maxLen + 2)}${scope}${flagStr}`);
|
||||||
}
|
}
|
||||||
console.log(`
|
console.log(`\nTotal: ${keys.length} keys (agent: ${cache.agent_id})`);
|
||||||
Total: ${keys.length} keys (agent: ${cache.agent_id})`);
|
|
||||||
console.log(`Last sync: ${cache.synced_at}`);
|
console.log(`Last sync: ${cache.synced_at}`);
|
||||||
}
|
}
|
||||||
function cmdToken(token) {
|
|
||||||
|
export function cmdToken(token: string): void {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
cfg.token = token;
|
cfg.token = token;
|
||||||
saveConfig(cfg);
|
saveConfig(cfg);
|
||||||
console.log("✓ Token saved");
|
console.log("✓ Token saved");
|
||||||
}
|
}
|
||||||
async function cmdAdminAddUser(args) {
|
|
||||||
|
export async function cmdFlags(args: string[]): Promise<void> {
|
||||||
|
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: Record<string, boolean> = {};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdAdminAddUser(args: string[]): Promise<void> {
|
||||||
const role = args.includes("--admin") ? "admin" : "agent";
|
const role = args.includes("--admin") ? "admin" : "agent";
|
||||||
const filtered = args.filter((a) => a !== "--admin");
|
const filtered = args.filter((a) => a !== "--admin");
|
||||||
if (!filtered[0]) {
|
if (!filtered[0]) {
|
||||||
console.error("Usage: cfg admin add <AGENT_ID> [--admin]");
|
console.error("Usage: cfg admin add <AGENT_ID> [--admin]");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const { data } = await api("POST", "/admin/token", { agent_id: filtered[0], role });
|
const { data } = await api("POST", "/admin/token", {
|
||||||
|
agent_id: filtered[0],
|
||||||
|
role,
|
||||||
|
});
|
||||||
console.log(`✓ Created ${data.role} token for ${data.agent_id}`);
|
console.log(`✓ Created ${data.role} token for ${data.agent_id}`);
|
||||||
console.log(` Token: ${data.token}`);
|
console.log(` Token: ${data.token}`);
|
||||||
}
|
}
|
||||||
async function cmdAdminRemoveUser(args) {
|
|
||||||
|
export async function cmdAdminRemoveUser(args: string[]): Promise<void> {
|
||||||
if (!args[0]) {
|
if (!args[0]) {
|
||||||
console.error("Usage: cfg admin remove <AGENT_ID>");
|
console.error("Usage: cfg admin remove <AGENT_ID>");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const { data } = await api("DELETE", `/admin/token/${encodeURIComponent(args[0])}`);
|
const { data } = await api(
|
||||||
|
"DELETE",
|
||||||
|
`/admin/token/${encodeURIComponent(args[0])}`
|
||||||
|
);
|
||||||
console.log(`✓ Revoked ${data.tokens_revoked} token(s) for ${data.agent_id}`);
|
console.log(`✓ Revoked ${data.tokens_revoked} token(s) for ${data.agent_id}`);
|
||||||
}
|
}
|
||||||
async function cmdAdminListAgents() {
|
|
||||||
|
export async function cmdAdminListAgents(): Promise<void> {
|
||||||
const { data } = await api("GET", "/admin/agents");
|
const { data } = await api("GET", "/admin/agents");
|
||||||
if (!data.agents?.length) {
|
if (!data.agents?.length) {
|
||||||
console.log("No agents found");
|
console.log("No agents found");
|
||||||
@ -298,7 +264,8 @@ async function cmdAdminListAgents() {
|
|||||||
console.log(` ${agent}`);
|
console.log(` ${agent}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function cmdAdminRefreshToken(args) {
|
|
||||||
|
export async function cmdAdminRefreshToken(args: string[]): Promise<void> {
|
||||||
if (!args[0]) {
|
if (!args[0]) {
|
||||||
console.error("Usage: cfg admin refresh <AGENT_ID>");
|
console.error("Usage: cfg admin refresh <AGENT_ID>");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@ -309,12 +276,16 @@ async function cmdAdminRefreshToken(args) {
|
|||||||
console.log(`✓ Refreshed token for ${data.agent_id}`);
|
console.log(`✓ Refreshed token for ${data.agent_id}`);
|
||||||
console.log(` Token: ${data.token}`);
|
console.log(` Token: ${data.token}`);
|
||||||
}
|
}
|
||||||
async function cmdAdminInspect(args) {
|
|
||||||
|
export async function cmdAdminInspect(args: string[]): Promise<void> {
|
||||||
if (!args[0]) {
|
if (!args[0]) {
|
||||||
console.error("Usage: cfg admin inspect <AGENT_ID>");
|
console.error("Usage: cfg admin inspect <AGENT_ID>");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const { data } = await api("GET", `/admin/agent/${encodeURIComponent(args[0])}`);
|
const { data } = await api(
|
||||||
|
"GET",
|
||||||
|
`/admin/agent/${encodeURIComponent(args[0])}`
|
||||||
|
);
|
||||||
if (!data.secrets || Object.keys(data.secrets).length === 0) {
|
if (!data.secrets || Object.keys(data.secrets).length === 0) {
|
||||||
console.log(`No keys for ${data.agent_id}`);
|
console.log(`No keys for ${data.agent_id}`);
|
||||||
return;
|
return;
|
||||||
@ -323,44 +294,16 @@ async function cmdAdminInspect(args) {
|
|||||||
const maxLen = Math.max(...keys.map((k) => k.length));
|
const maxLen = Math.max(...keys.map((k) => k.length));
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const entry = data.secrets[key];
|
const entry = data.secrets[key];
|
||||||
const scope = entry.scope === "personal" ? "\x1B[32mpersonal\x1B[0m" : "\x1B[34mshared\x1B[0m ";
|
const scope =
|
||||||
|
entry.scope === "personal"
|
||||||
|
? "\x1B[32mpersonal\x1B[0m"
|
||||||
|
: "\x1B[34mshared\x1B[0m ";
|
||||||
console.log(` ${key.padEnd(maxLen + 2)}${scope}`);
|
console.log(` ${key.padEnd(maxLen + 2)}${scope}`);
|
||||||
}
|
}
|
||||||
console.log(`
|
console.log(`\nTotal: ${keys.length} keys (agent: ${data.agent_id})`);
|
||||||
Total: ${keys.length} keys (agent: ${data.agent_id})`);
|
|
||||||
}
|
}
|
||||||
async function cmdFlags(args) {
|
|
||||||
const shared = args.includes("--shared");
|
export function showHelp(): void {
|
||||||
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
|
console.log(`cfg — config.shazhou.work CLI
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@ -399,76 +342,3 @@ Environment:
|
|||||||
Shell setup:
|
Shell setup:
|
||||||
eval $(cfg env) Add to .profile / .bashrc / .zshrc`);
|
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);
|
|
||||||
}
|
|
||||||
73
packages/cfg/src/config.ts
Normal file
73
packages/cfg/src/config.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { homedir } from "os";
|
||||||
|
|
||||||
|
export const CONFIG_DIR = join(homedir(), ".config", "cfg");
|
||||||
|
export const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
||||||
|
export const CACHE_FILE = join(CONFIG_DIR, "cache.json");
|
||||||
|
export const DEFAULT_ENDPOINT = "https://config.shazhou.work";
|
||||||
|
export const ONE_DAY = 24 * 60 * 60 * 1000;
|
||||||
|
export const TWO_HOURS = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
token?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretEntry {
|
||||||
|
value: string;
|
||||||
|
scope?: string;
|
||||||
|
env?: boolean;
|
||||||
|
secret?: boolean;
|
||||||
|
updated_at?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cache {
|
||||||
|
agent_id: string;
|
||||||
|
secrets: Record<string, SecretEntry>;
|
||||||
|
synced_at: string;
|
||||||
|
attempted_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(): Config {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveConfig(cfg: Config): void {
|
||||||
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
||||||
|
writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCache(): Cache | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCache(cache: Cache): void {
|
||||||
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
||||||
|
writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken(): string {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEndpoint(): string {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
return cfg.endpoint || process.env.CFG_ENDPOINT || DEFAULT_ENDPOINT;
|
||||||
|
}
|
||||||
12
packages/cfg/tsconfig.json
Normal file
12
packages/cfg/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
381
packages/worker/src/ui.ts
Normal file
381
packages/worker/src/ui.ts
Normal file
@ -0,0 +1,381 @@
|
|||||||
|
export function renderUI(): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Config Service</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e1e4e8; min-height: 100vh; }
|
||||||
|
|
||||||
|
.login { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||||
|
.login-box { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 32px; width: 360px; }
|
||||||
|
.login-box h1 { font-size: 20px; margin-bottom: 20px; color: #f0f6fc; }
|
||||||
|
.login-box input { width: 100%; padding: 10px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; font-size: 14px; margin-bottom: 12px; }
|
||||||
|
.login-box input:focus { outline: none; border-color: #58a6ff; }
|
||||||
|
.login-box button { width: 100%; padding: 10px; background: #238636; border: none; border-radius: 6px; color: #fff; font-size: 14px; cursor: pointer; font-weight: 600; }
|
||||||
|
.login-box button:hover { background: #2ea043; }
|
||||||
|
|
||||||
|
.app { display: none; }
|
||||||
|
.header { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.header h1 { font-size: 18px; font-weight: 600; }
|
||||||
|
.header .info { display: flex; align-items: center; gap: 12px; font-size: 13px; color: #8b949e; }
|
||||||
|
.badge { padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
|
||||||
|
.badge.admin { background: #da3633; color: #fff; }
|
||||||
|
.badge.agent { background: #1f6feb; color: #fff; }
|
||||||
|
.badge.shared { background: #388bfd26; color: #58a6ff; border: 1px solid #388bfd66; }
|
||||||
|
.badge.personal { background: #23863626; color: #3fb950; border: 1px solid #2ea04366; }
|
||||||
|
|
||||||
|
.toolbar { padding: 16px 24px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.toolbar input { padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; font-size: 13px; width: 240px; }
|
||||||
|
.toolbar select { padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; font-size: 13px; }
|
||||||
|
.toolbar button { padding: 8px 16px; border: 1px solid #30363d; border-radius: 6px; background: #21262d; color: #e1e4e8; font-size: 13px; cursor: pointer; }
|
||||||
|
.toolbar button:hover { background: #30363d; }
|
||||||
|
.toolbar button.primary { background: #238636; border-color: #238636; }
|
||||||
|
.toolbar button.primary:hover { background: #2ea043; }
|
||||||
|
.toolbar .spacer { flex: 1; }
|
||||||
|
|
||||||
|
.table-wrap { padding: 0 24px 24px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
|
||||||
|
th { text-align: left; padding: 10px 16px; background: #1c2128; font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid #30363d; }
|
||||||
|
td { padding: 10px 16px; border-bottom: 1px solid #21262d; font-size: 13px; vertical-align: middle; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: #1c2128; }
|
||||||
|
|
||||||
|
.val { font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; font-size: 12px; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; color: #8b949e; }
|
||||||
|
.val.revealed { color: #e1e4e8; white-space: pre-wrap; word-break: break-all; }
|
||||||
|
.val:hover { color: #58a6ff; }
|
||||||
|
.time { color: #8b949e; font-size: 12px; }
|
||||||
|
.actions button { padding: 4px 10px; font-size: 12px; border-radius: 4px; border: 1px solid #30363d; background: #21262d; color: #e1e4e8; cursor: pointer; margin-right: 4px; }
|
||||||
|
.actions button:hover { background: #30363d; }
|
||||||
|
.actions button.danger { color: #f85149; }
|
||||||
|
.actions button.danger:hover { background: #da363322; }
|
||||||
|
|
||||||
|
.modal-overlay { display: none; position: fixed; inset: 0; background: #00000088; z-index: 100; align-items: center; justify-content: center; }
|
||||||
|
.modal-overlay.active { display: flex; }
|
||||||
|
.modal { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 24px; width: 440px; }
|
||||||
|
.modal h2 { font-size: 16px; margin-bottom: 16px; }
|
||||||
|
.modal label { display: block; font-size: 13px; color: #8b949e; margin-bottom: 4px; margin-top: 12px; }
|
||||||
|
.modal input, .modal textarea { width: 100%; padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; font-size: 13px; font-family: inherit; }
|
||||||
|
.modal textarea { min-height: 80px; resize: vertical; font-family: 'SF Mono', Monaco, monospace; }
|
||||||
|
.modal .btns { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
|
||||||
|
.modal .btns button { padding: 8px 16px; border-radius: 6px; border: none; font-size: 13px; cursor: pointer; font-weight: 600; }
|
||||||
|
.modal .cancel { background: #21262d; color: #e1e4e8; border: 1px solid #30363d; }
|
||||||
|
.modal .save { background: #238636; color: #fff; }
|
||||||
|
|
||||||
|
.agent-tabs { padding: 0 24px; display: flex; gap: 0; margin-bottom: 16px; flex-wrap: wrap; }
|
||||||
|
.agent-tab { padding: 8px 16px; font-size: 13px; border: 1px solid #30363d; background: #0d1117; color: #8b949e; cursor: pointer; border-bottom: none; }
|
||||||
|
.agent-tab:first-child { border-radius: 6px 0 0 0; }
|
||||||
|
.agent-tab:last-child { border-radius: 0 6px 0 0; }
|
||||||
|
.agent-tab.active { background: #161b22; color: #f0f6fc; border-bottom: 2px solid #58a6ff; }
|
||||||
|
.agent-tab:hover { color: #e1e4e8; }
|
||||||
|
|
||||||
|
.empty { text-align: center; padding: 40px; color: #8b949e; }
|
||||||
|
.toast { position: fixed; bottom: 24px; right: 24px; padding: 12px 20px; background: #238636; color: #fff; border-radius: 8px; font-size: 13px; z-index: 200; display: none; }
|
||||||
|
.toast.error { background: #da3633; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="login" id="loginView">
|
||||||
|
<div class="login-box">
|
||||||
|
<h1>🔑 Config Service</h1>
|
||||||
|
<input type="password" id="tokenInput" placeholder="Enter your token..." autofocus>
|
||||||
|
<button onclick="doLogin()">Sign In</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app" id="appView">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🔑 Config Service</h1>
|
||||||
|
<div class="info">
|
||||||
|
<span id="agentName"></span>
|
||||||
|
<span id="roleBadge"></span>
|
||||||
|
<button onclick="doLogout()" style="padding:4px 12px;font-size:12px;border:1px solid #30363d;border-radius:4px;background:#21262d;color:#e1e4e8;cursor:pointer;">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent-tabs" id="agentTabs" style="display:none;"></div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<input type="text" id="searchInput" placeholder="Filter keys..." oninput="renderTable()">
|
||||||
|
<select id="scopeFilter" onchange="renderTable()">
|
||||||
|
<option value="all">All scopes</option>
|
||||||
|
<option value="shared">Shared only</option>
|
||||||
|
<option value="personal">Personal only</option>
|
||||||
|
</select>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="primary" onclick="openAddModal()">+ Add Key</button>
|
||||||
|
<button onclick="loadData()">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Key</th><th>Value</th><th>Scope</th><th>Updated</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody id="tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="addModal">
|
||||||
|
<div class="modal">
|
||||||
|
<h2 id="modalTitle">Add Key</h2>
|
||||||
|
<label>Key</label>
|
||||||
|
<input type="text" id="modalKey" placeholder="e.g. API_KEY">
|
||||||
|
<label>Value</label>
|
||||||
|
<textarea id="modalValue" placeholder="secret value"></textarea>
|
||||||
|
<label>Scope</label>
|
||||||
|
<select id="modalScope" style="width:100%;padding:8px 12px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#e1e4e8;font-size:13px;">
|
||||||
|
<option value="personal">Personal</option>
|
||||||
|
<option value="shared">Shared (admin only)</option>
|
||||||
|
</select>
|
||||||
|
<div class="btns">
|
||||||
|
<button class="cancel" onclick="closeModal()">Cancel</button>
|
||||||
|
<button class="save" onclick="saveKey()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let TOKEN = localStorage.getItem('cfg_token') || '';
|
||||||
|
let authInfo = null;
|
||||||
|
let allData = {};
|
||||||
|
let currentAgent = null; // for admin viewing other agents
|
||||||
|
let allAgents = []; // admin only: list of all agents
|
||||||
|
|
||||||
|
const BASE = '';
|
||||||
|
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const opts = { method, headers: { 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' } };
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
const r = await fetch(BASE + path, opts);
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({ error: 'request failed' }));
|
||||||
|
throw new Error(e.error || 'request failed');
|
||||||
|
}
|
||||||
|
if (r.status === 204) return {};
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogin() {
|
||||||
|
TOKEN = document.getElementById('tokenInput').value.trim();
|
||||||
|
if (!TOKEN) return;
|
||||||
|
try {
|
||||||
|
const data = await api('GET', '/config');
|
||||||
|
localStorage.setItem('cfg_token', TOKEN);
|
||||||
|
authInfo = { agent_id: data.agent_id, role: data.secrets ? 'admin' : 'agent' };
|
||||||
|
// Detect role from response
|
||||||
|
if (data.agent_id) {
|
||||||
|
authInfo.agent_id = data.agent_id;
|
||||||
|
}
|
||||||
|
document.getElementById('loginView').style.display = 'none';
|
||||||
|
document.getElementById('appView').style.display = 'block';
|
||||||
|
await initApp(data);
|
||||||
|
} catch (e) {
|
||||||
|
toast('Invalid token', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doLogout() {
|
||||||
|
localStorage.removeItem('cfg_token');
|
||||||
|
TOKEN = '';
|
||||||
|
authInfo = null;
|
||||||
|
document.getElementById('loginView').style.display = '';
|
||||||
|
document.getElementById('appView').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initApp(data) {
|
||||||
|
// Get auth info by checking the whoami-like response
|
||||||
|
// The /config (list all) response includes agent_id
|
||||||
|
allData = data.secrets || {};
|
||||||
|
authInfo = { agent_id: data.agent_id, role: 'agent' };
|
||||||
|
|
||||||
|
// Check if admin by trying to list all agents (admin can see shared scope)
|
||||||
|
try {
|
||||||
|
// Try admin-level endpoint: list all personal keys across agents
|
||||||
|
const sharedList = await api('GET', '/config?scope=shared');
|
||||||
|
if (sharedList.keys) {
|
||||||
|
authInfo.role = 'admin';
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// Actually we can detect admin from the auth endpoint. For now, let's check
|
||||||
|
// if user can write shared scope as a test... or just show what we have.
|
||||||
|
// The sync response already gives us everything we need.
|
||||||
|
|
||||||
|
document.getElementById('agentName').textContent = data.agent_id;
|
||||||
|
const badge = document.getElementById('roleBadge');
|
||||||
|
badge.textContent = authInfo.role;
|
||||||
|
badge.className = 'badge ' + authInfo.role;
|
||||||
|
|
||||||
|
if (authInfo.role === 'admin') {
|
||||||
|
await loadAdminAgentList();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAdminAgentList() {
|
||||||
|
// Admin: discover agents by listing all personal: keys
|
||||||
|
try {
|
||||||
|
const resp = await api('GET', '/admin/agents');
|
||||||
|
if (resp.agents) {
|
||||||
|
allAgents = resp.agents;
|
||||||
|
renderAgentTabs();
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
// endpoint might not exist yet, just skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgentTabs() {
|
||||||
|
const tabs = document.getElementById('agentTabs');
|
||||||
|
if (allAgents.length === 0) { tabs.style.display = 'none'; return; }
|
||||||
|
tabs.style.display = 'flex';
|
||||||
|
tabs.innerHTML = '<div class="agent-tab active" onclick="switchAgent(null)">My View</div>' +
|
||||||
|
allAgents.map(a => '<div class="agent-tab" onclick="switchAgent(\\'' + a + '\\')">' + a + '</div>').join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchAgent(agentId) {
|
||||||
|
currentAgent = agentId;
|
||||||
|
document.querySelectorAll('.agent-tab').forEach((t, i) => {
|
||||||
|
t.classList.toggle('active', agentId === null ? i === 0 : t.textContent === agentId);
|
||||||
|
});
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
let data;
|
||||||
|
if (currentAgent && authInfo.role === 'admin') {
|
||||||
|
data = await api('GET', '/admin/agent/' + currentAgent);
|
||||||
|
} else {
|
||||||
|
data = await api('GET', '/config');
|
||||||
|
}
|
||||||
|
allData = data.secrets || {};
|
||||||
|
renderTable();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const search = document.getElementById('searchInput').value.toLowerCase();
|
||||||
|
const scopeFilter = document.getElementById('scopeFilter').value;
|
||||||
|
const tbody = document.getElementById('tbody');
|
||||||
|
|
||||||
|
const entries = Object.entries(allData)
|
||||||
|
.filter(([k, v]) => {
|
||||||
|
if (search && !k.toLowerCase().includes(search)) return false;
|
||||||
|
if (scopeFilter !== 'all' && v.scope !== scopeFilter) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="empty">No keys found</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = entries.map(([key, v]) => {
|
||||||
|
const masked = '•'.repeat(Math.min(v.value.length, 32));
|
||||||
|
const time = v.updated_at ? new Date(v.updated_at).toLocaleString() : '-';
|
||||||
|
return '<tr>' +
|
||||||
|
'<td><strong>' + esc(key) + '</strong></td>' +
|
||||||
|
'<td><span class="val" onclick="toggleReveal(this)" data-val="' + esc(v.value) + '">' + masked + '</span></td>' +
|
||||||
|
'<td><span class="badge ' + v.scope + '">' + v.scope + '</span></td>' +
|
||||||
|
'<td class="time">' + time + '</td>' +
|
||||||
|
'<td class="actions">' +
|
||||||
|
'<button onclick="copyVal(\\'' + esc(key) + '\\')">Copy</button>' +
|
||||||
|
'<button onclick="editKey(\\'' + esc(key) + '\\')">Edit</button>' +
|
||||||
|
'<button class="danger" onclick="deleteKey(\\'' + esc(key) + '\\', \\'' + v.scope + '\\')">Del</button>' +
|
||||||
|
'</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReveal(el) {
|
||||||
|
if (el.classList.contains('revealed')) {
|
||||||
|
el.textContent = '•'.repeat(Math.min(el.dataset.val.length, 32));
|
||||||
|
el.classList.remove('revealed');
|
||||||
|
} else {
|
||||||
|
el.textContent = el.dataset.val;
|
||||||
|
el.classList.add('revealed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyVal(key) {
|
||||||
|
const v = allData[key];
|
||||||
|
if (v) navigator.clipboard.writeText(v.value).then(() => toast('Copied!'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
|
}
|
||||||
|
|
||||||
|
let editingKey = null;
|
||||||
|
function openAddModal() {
|
||||||
|
editingKey = null;
|
||||||
|
document.getElementById('modalTitle').textContent = 'Add Key';
|
||||||
|
document.getElementById('modalKey').value = '';
|
||||||
|
document.getElementById('modalKey').disabled = false;
|
||||||
|
document.getElementById('modalValue').value = '';
|
||||||
|
document.getElementById('modalScope').value = 'personal';
|
||||||
|
document.getElementById('addModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editKey(key) {
|
||||||
|
editingKey = key;
|
||||||
|
const v = allData[key];
|
||||||
|
document.getElementById('modalTitle').textContent = 'Edit: ' + key;
|
||||||
|
document.getElementById('modalKey').value = key;
|
||||||
|
document.getElementById('modalKey').disabled = true;
|
||||||
|
document.getElementById('modalValue').value = v.value;
|
||||||
|
document.getElementById('modalScope').value = v.scope;
|
||||||
|
document.getElementById('addModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('addModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveKey() {
|
||||||
|
const key = document.getElementById('modalKey').value.trim();
|
||||||
|
const value = document.getElementById('modalValue').value;
|
||||||
|
const scope = document.getElementById('modalScope').value;
|
||||||
|
if (!key) { toast('Key is required', true); return; }
|
||||||
|
try {
|
||||||
|
await api('PUT', '/config/' + encodeURIComponent(key) + '?scope=' + scope, { value });
|
||||||
|
toast('Saved!');
|
||||||
|
closeModal();
|
||||||
|
await loadData();
|
||||||
|
} catch(e) { toast(e.message, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteKey(key, scope) {
|
||||||
|
if (!confirm('Delete ' + key + ' from ' + scope + '?')) return;
|
||||||
|
try {
|
||||||
|
await api('DELETE', '/config/' + encodeURIComponent(key) + '?scope=' + scope);
|
||||||
|
toast('Deleted');
|
||||||
|
await loadData();
|
||||||
|
} catch(e) { toast(e.message, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg, isError) {
|
||||||
|
const t = document.getElementById('toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.className = 'toast' + (isError ? ' error' : '');
|
||||||
|
t.style.display = 'block';
|
||||||
|
setTimeout(() => t.style.display = 'none', 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-login if token saved
|
||||||
|
if (TOKEN) {
|
||||||
|
doLogin();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@ -1,43 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Register an agent token in the config service KV.
|
|
||||||
|
|
||||||
Usage: python3 register_agent.py <agent_id> <role> [token]
|
|
||||||
|
|
||||||
If token is omitted, a random one is generated.
|
|
||||||
Outputs the KV entry to add via wrangler CLI.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import secrets
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if len(sys.argv) < 3:
|
|
||||||
print(f"Usage: {sys.argv[0]} <agent_id> <role> [token]")
|
|
||||||
print(" role: agent or admin")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
agent_id = sys.argv[1]
|
|
||||||
role = sys.argv[2]
|
|
||||||
token = sys.argv[3] if len(sys.argv) > 3 else secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
if role not in ("agent", "admin"):
|
|
||||||
print("Role must be 'agent' or 'admin'", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
||||||
entry = json.dumps({"agent_id": agent_id, "role": role})
|
|
||||||
|
|
||||||
print(f"Agent ID: {agent_id}")
|
|
||||||
print(f"Role: {role}")
|
|
||||||
print(f"Token: {token}")
|
|
||||||
print(f"Token hash: {token_hash}")
|
|
||||||
print()
|
|
||||||
print("Add to KV:")
|
|
||||||
print(f' wrangler kv key put --binding CONFIG_KV "auth:{token_hash}" \'{entry}\'')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Loading…
x
Reference in New Issue
Block a user