Initial: saiden-config CLI for config.saiden.dev
This commit is contained in:
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "saiden-config",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "saiden-config",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"saiden-config": "./src/cli.ts"
|
||||
},
|
||||
"files": ["src/"],
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
}
|
||||
}
|
||||
Executable
+253
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { loadConfig, saveConfig, ensureApiKey } from "./config";
|
||||
import { Client, ApiError } from "./client";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
const sub = args[1];
|
||||
|
||||
function usage(): never {
|
||||
console.log(`saiden-config — CLI for config.saiden.dev
|
||||
|
||||
Usage:
|
||||
saiden-config auth <api-key> Store API key
|
||||
saiden-config auth show Show current config
|
||||
|
||||
saiden-config secrets list <project> <env>
|
||||
saiden-config secrets get <project> <env> <key>
|
||||
saiden-config secrets get <project> <env> <key> --plain
|
||||
saiden-config secrets set <project> <env> <key> <value>
|
||||
saiden-config secrets set <project> <env> <key> -f <file>
|
||||
saiden-config secrets delete <project> <env> <key>
|
||||
|
||||
saiden-config files list <project> <env>
|
||||
saiden-config files get <project> <env> <path>
|
||||
saiden-config files put <project> <env> <path> -f <file>
|
||||
saiden-config files put <project> <env> <path> <content>
|
||||
saiden-config files delete <project> <env> <path>
|
||||
|
||||
saiden-config projects list
|
||||
saiden-config projects get <project>
|
||||
saiden-config projects set <project> --gitea <url> [--coolify-app <id>] [--desc <text>]
|
||||
saiden-config projects delete <project>
|
||||
|
||||
saiden-config sync <project> <env>
|
||||
|
||||
Environment:
|
||||
SAIDEN_CONFIG_API_KEY API key (overrides config file)
|
||||
SAIDEN_CONFIG_URL Base URL (default: https://config.saiden.dev)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function flag(name: string): string | undefined {
|
||||
const idx = args.indexOf(name);
|
||||
if (idx === -1) return undefined;
|
||||
return args[idx + 1];
|
||||
}
|
||||
|
||||
function hasFlag(name: string): boolean {
|
||||
return args.includes(name);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!command) usage();
|
||||
|
||||
// --- Auth ---
|
||||
if (command === "auth") {
|
||||
if (sub === "show") {
|
||||
const config = loadConfig();
|
||||
console.log(`URL: ${config.baseUrl}`);
|
||||
console.log(`API Key: ${config.apiKey ? config.apiKey.slice(0, 8) + "..." : "(not set)"}`);
|
||||
return;
|
||||
}
|
||||
if (!sub) {
|
||||
console.error("Usage: saiden-config auth <api-key>");
|
||||
process.exit(1);
|
||||
}
|
||||
saveConfig({ apiKey: sub });
|
||||
console.log("API key stored.");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
ensureApiKey(config);
|
||||
const client = new Client(config);
|
||||
|
||||
try {
|
||||
// --- Secrets ---
|
||||
if (command === "secrets") {
|
||||
const project = args[2];
|
||||
const env = args[3];
|
||||
|
||||
if (sub === "list") {
|
||||
if (!project || !env) { console.error("Usage: saiden-config secrets list <project> <env>"); process.exit(1); }
|
||||
const result = await client.listSecrets(project, env);
|
||||
if (result.keys.length === 0) {
|
||||
console.log("(no secrets)");
|
||||
} else {
|
||||
for (const k of result.keys) console.log(k);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "get") {
|
||||
const key = args[4];
|
||||
if (!project || !env || !key) { console.error("Usage: saiden-config secrets get <project> <env> <key>"); process.exit(1); }
|
||||
const result = await client.getSecret(project, env, key);
|
||||
if (hasFlag("--plain")) {
|
||||
process.stdout.write(result.value);
|
||||
} else {
|
||||
console.log(`${result.key}=${result.value}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "set") {
|
||||
const key = args[4];
|
||||
if (!project || !env || !key) { console.error("Usage: saiden-config secrets set <project> <env> <key> <value>"); process.exit(1); }
|
||||
const file = flag("-f");
|
||||
const value = file ? readFileSync(file, "utf-8") : args[5];
|
||||
if (value === undefined) { console.error("Provide a value or -f <file>"); process.exit(1); }
|
||||
await client.setSecret(project, env, key, value);
|
||||
console.log(`Stored: ${project}/${env}/${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "delete") {
|
||||
const key = args[4];
|
||||
if (!project || !env || !key) { console.error("Usage: saiden-config secrets delete <project> <env> <key>"); process.exit(1); }
|
||||
await client.deleteSecret(project, env, key);
|
||||
console.log(`Deleted: ${project}/${env}/${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Unknown secrets command: " + sub);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- Files ---
|
||||
if (command === "files") {
|
||||
const project = args[2];
|
||||
const env = args[3];
|
||||
|
||||
if (sub === "list") {
|
||||
if (!project || !env) { console.error("Usage: saiden-config files list <project> <env>"); process.exit(1); }
|
||||
const result = await client.listFiles(project, env);
|
||||
if (result.files.length === 0) {
|
||||
console.log("(no files)");
|
||||
} else {
|
||||
for (const f of result.files as { path: string; size?: number; contentType?: string }[]) {
|
||||
console.log(`${f.path} ${f.contentType ?? ""} ${f.size ?? ""}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "get") {
|
||||
const path = args[4];
|
||||
if (!project || !env || !path) { console.error("Usage: saiden-config files get <project> <env> <path>"); process.exit(1); }
|
||||
const content = await client.getFile(project, env, path);
|
||||
process.stdout.write(content);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "put") {
|
||||
const path = args[4];
|
||||
if (!project || !env || !path) { console.error("Usage: saiden-config files put <project> <env> <path> -f <file>|<content>"); process.exit(1); }
|
||||
const file = flag("-f");
|
||||
const content = file ? readFileSync(file, "utf-8") : args[5];
|
||||
if (content === undefined) { console.error("Provide content or -f <file>"); process.exit(1); }
|
||||
await client.putFile(project, env, path, content);
|
||||
console.log(`Stored: ${project}/${env}/files/${path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "delete") {
|
||||
const path = args[4];
|
||||
if (!project || !env || !path) { console.error("Usage: saiden-config files delete <project> <env> <path>"); process.exit(1); }
|
||||
await client.deleteFile(project, env, path);
|
||||
console.log(`Deleted: ${project}/${env}/files/${path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Unknown files command: " + sub);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- Projects ---
|
||||
if (command === "projects") {
|
||||
if (sub === "list") {
|
||||
const result = await client.listProjects();
|
||||
if (result.projects.length === 0) {
|
||||
console.log("(no projects registered)");
|
||||
} else {
|
||||
for (const p of result.projects as { project: string; gitea: string; coolifyAppId?: string; description?: string }[]) {
|
||||
const parts = [p.project.padEnd(20), p.gitea];
|
||||
if (p.coolifyAppId) parts.push(`coolify:${p.coolifyAppId}`);
|
||||
if (p.description) parts.push(`(${p.description})`);
|
||||
console.log(parts.join(" "));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "get") {
|
||||
const project = args[2];
|
||||
if (!project) { console.error("Usage: saiden-config projects get <project>"); process.exit(1); }
|
||||
const result = await client.getProject(project);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "set") {
|
||||
const project = args[2];
|
||||
const gitea = flag("--gitea");
|
||||
if (!project || !gitea) { console.error("Usage: saiden-config projects set <project> --gitea <url> [--coolify-app <id>] [--desc <text>]"); process.exit(1); }
|
||||
const result = await client.setProject(project, {
|
||||
gitea,
|
||||
coolifyAppId: flag("--coolify-app"),
|
||||
description: flag("--desc"),
|
||||
});
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "delete") {
|
||||
const project = args[2];
|
||||
if (!project) { console.error("Usage: saiden-config projects delete <project>"); process.exit(1); }
|
||||
await client.deleteProject(project);
|
||||
console.log(`Deleted project: ${project}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Unknown projects command: " + sub);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- Sync ---
|
||||
if (command === "sync") {
|
||||
const project = args[1];
|
||||
const env = args[2];
|
||||
if (!project || !env) { console.error("Usage: saiden-config sync <project> <env>"); process.exit(1); }
|
||||
const result = await client.syncCoolify(project, env) as { synced?: number; errors?: unknown[]; total?: number };
|
||||
console.log(`Synced ${result.synced ?? 0}/${result.total ?? 0} env vars to Coolify`);
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
console.error("Errors:");
|
||||
for (const e of result.errors) console.error(` ${JSON.stringify(e)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
usage();
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
console.error(`Error: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import type { Config } from "./config";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, public body: unknown) {
|
||||
super(`HTTP ${status}: ${typeof body === "string" ? body : JSON.stringify(body)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class Client {
|
||||
constructor(private config: Config) {}
|
||||
|
||||
private async request(method: string, path: string, body?: unknown): Promise<Response> {
|
||||
const url = `${this.config.baseUrl}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
};
|
||||
|
||||
const init: RequestInit = { method, headers };
|
||||
|
||||
if (body !== undefined) {
|
||||
if (typeof body === "string") {
|
||||
// Raw string body (for file uploads)
|
||||
init.body = body;
|
||||
} else {
|
||||
headers["Content-Type"] = "application/json";
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await fetch(url, init);
|
||||
if (!resp.ok && resp.status !== 207) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(text); } catch { parsed = text; }
|
||||
throw new ApiError(resp.status, parsed);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
// --- Secrets ---
|
||||
|
||||
async listSecrets(project: string, env: string): Promise<{ keys: string[]; complete: boolean }> {
|
||||
const resp = await this.request("GET", `/${project}/${env}/secrets`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async getSecret(project: string, env: string, key: string): Promise<{ key: string; value: string }> {
|
||||
const resp = await this.request("GET", `/${project}/${env}/secrets/${key}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async setSecret(project: string, env: string, key: string, value: string): Promise<unknown> {
|
||||
const resp = await this.request("PUT", `/${project}/${env}/secrets/${key}`, { value });
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async deleteSecret(project: string, env: string, key: string): Promise<unknown> {
|
||||
const resp = await this.request("DELETE", `/${project}/${env}/secrets/${key}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// --- Files ---
|
||||
|
||||
async listFiles(project: string, env: string): Promise<{ files: unknown[]; complete: boolean }> {
|
||||
const resp = await this.request("GET", `/${project}/${env}/files`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async getFile(project: string, env: string, path: string): Promise<string> {
|
||||
const resp = await this.request("GET", `/${project}/${env}/files/${path}`);
|
||||
return resp.text();
|
||||
}
|
||||
|
||||
async putFile(project: string, env: string, path: string, content: string, contentType?: string): Promise<unknown> {
|
||||
const url = `${this.config.baseUrl}/${project}/${env}/files/${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
};
|
||||
if (contentType) headers["Content-Type"] = contentType;
|
||||
const resp = await fetch(url, { method: "PUT", headers, body: content });
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
throw new ApiError(resp.status, text);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async deleteFile(project: string, env: string, path: string): Promise<unknown> {
|
||||
const resp = await this.request("DELETE", `/${project}/${env}/files/${path}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// --- Projects ---
|
||||
|
||||
async listProjects(): Promise<{ projects: unknown[] }> {
|
||||
const resp = await this.request("GET", "/projects");
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async getProject(project: string): Promise<unknown> {
|
||||
const resp = await this.request("GET", `/${project}/meta`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async setProject(project: string, data: { gitea: string; coolifyAppId?: string; description?: string }): Promise<unknown> {
|
||||
const resp = await this.request("PUT", `/${project}/meta`, data);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async deleteProject(project: string): Promise<unknown> {
|
||||
const resp = await this.request("DELETE", `/${project}/meta`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// --- Sync ---
|
||||
|
||||
async syncCoolify(project: string, env: string): Promise<unknown> {
|
||||
const resp = await this.request("POST", `/${project}/${env}/sync-coolify`);
|
||||
return resp.json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
||||
|
||||
export interface Config {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
const CONFIG_DIR = join(homedir(), ".config", "saiden-config");
|
||||
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
||||
|
||||
const DEFAULT_BASE_URL = "https://config.saiden.dev";
|
||||
|
||||
export function loadConfig(): Config {
|
||||
// Env vars take precedence
|
||||
const envKey = process.env.SAIDEN_CONFIG_API_KEY;
|
||||
const envUrl = process.env.SAIDEN_CONFIG_URL;
|
||||
|
||||
if (existsSync(CONFIG_FILE)) {
|
||||
const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
||||
return {
|
||||
baseUrl: envUrl ?? raw.baseUrl ?? DEFAULT_BASE_URL,
|
||||
apiKey: envKey ?? raw.apiKey ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: envUrl ?? DEFAULT_BASE_URL,
|
||||
apiKey: envKey ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function saveConfig(config: Partial<Config>): void {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
const existing = existsSync(CONFIG_FILE)
|
||||
? JSON.parse(readFileSync(CONFIG_FILE, "utf-8"))
|
||||
: {};
|
||||
const merged = { ...existing, ...config };
|
||||
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + "\n");
|
||||
}
|
||||
|
||||
export function ensureApiKey(config: Config): void {
|
||||
if (!config.apiKey) {
|
||||
console.error("No API key configured.");
|
||||
console.error("Run: saiden-config auth <api-key>");
|
||||
console.error("Or set SAIDEN_CONFIG_API_KEY env var.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ESNext"],
|
||||
"types": ["bun-types"],
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user