From 515249206c5b1c18271ad04dd279458aa2b1e902 Mon Sep 17 00:00:00 2001 From: marauder-actual Date: Wed, 10 Jun 2026 18:18:32 +0200 Subject: [PATCH] Initial: saiden-config CLI for config.saiden.dev --- .gitignore | 1 + bun.lock | 21 +++++ package.json | 12 +++ src/cli.ts | 253 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/client.ts | 121 ++++++++++++++++++++++++ src/config.ts | 50 ++++++++++ tsconfig.json | 14 +++ 7 files changed, 472 insertions(+) create mode 100644 .gitignore create mode 100644 bun.lock create mode 100644 package.json create mode 100755 src/cli.ts create mode 100644 src/client.ts create mode 100644 src/config.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..bf8c526 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6d27867 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100755 index 0000000..3cd4946 --- /dev/null +++ b/src/cli.ts @@ -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 Store API key + saiden-config auth show Show current config + + saiden-config secrets list + saiden-config secrets get + saiden-config secrets get --plain + saiden-config secrets set + saiden-config secrets set -f + saiden-config secrets delete + + saiden-config files list + saiden-config files get + saiden-config files put -f + saiden-config files put + saiden-config files delete + + saiden-config projects list + saiden-config projects get + saiden-config projects set --gitea [--coolify-app ] [--desc ] + saiden-config projects delete + + saiden-config sync + +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 "); + 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 "); 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 "); 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 "); 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 "); 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 "); 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 "); 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 "); 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 -f |"); 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 "); 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 "); 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 "); 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 --gitea [--coolify-app ] [--desc ]"); 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 "); 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 "); 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(); diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..d6afdc6 --- /dev/null +++ b/src/client.ts @@ -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 { + const url = `${this.config.baseUrl}${path}`; + const headers: Record = { + 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 { + const resp = await this.request("PUT", `/${project}/${env}/secrets/${key}`, { value }); + return resp.json(); + } + + async deleteSecret(project: string, env: string, key: string): Promise { + 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 { + 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 { + const url = `${this.config.baseUrl}/${project}/${env}/files/${path}`; + const headers: Record = { + 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 { + 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 { + const resp = await this.request("GET", `/${project}/meta`); + return resp.json(); + } + + async setProject(project: string, data: { gitea: string; coolifyAppId?: string; description?: string }): Promise { + const resp = await this.request("PUT", `/${project}/meta`, data); + return resp.json(); + } + + async deleteProject(project: string): Promise { + const resp = await this.request("DELETE", `/${project}/meta`); + return resp.json(); + } + + // --- Sync --- + + async syncCoolify(project: string, env: string): Promise { + const resp = await this.request("POST", `/${project}/${env}/sync-coolify`); + return resp.json(); + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..c2d673b --- /dev/null +++ b/src/config.ts @@ -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): 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 "); + console.error("Or set SAIDEN_CONFIG_API_KEY env var."); + process.exit(1); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..488debe --- /dev/null +++ b/tsconfig.json @@ -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"] +}