Initial: saiden-config CLI for config.saiden.dev

This commit is contained in:
marauder-actual
2026-06-10 18:18:32 +02:00
commit 515249206c
7 changed files with 472 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
node_modules/
+21
View File
@@ -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=="],
}
}
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
}
+50
View File
@@ -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);
}
}
+14
View File
@@ -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"]
}