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