diff --git a/package.json b/package.json index a27d07c..e34619c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@madcat-os/saiden-config", - "version": "0.1.5", + "version": "0.2.0", "type": "module", "bin": { "saiden-config": "bin/saiden-config.js" diff --git a/src/cli.ts b/src/cli.ts index 3cd4946..78361df 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,7 @@ import { loadConfig, saveConfig, ensureApiKey } from "./config"; import { Client, ApiError } from "./client"; import { readFileSync } from "fs"; +import { dotfilesList, dotfilesPush, dotfilesPull, dotfilesSync, dotfilesDiff, dotfilesInit } from "./dotfiles"; const args = process.argv.slice(2); const command = args[0]; @@ -35,6 +36,17 @@ Usage: saiden-config sync + saiden-config dotfiles init Upload default manifest to KV + saiden-config dotfiles ls List all tracked dotfiles + saiden-config dotfiles push Push a local dotfile to KV + saiden-config dotfiles push --all Push all dotfiles for this host + saiden-config dotfiles pull Pull a dotfile from KV to local + saiden-config dotfiles pull --all Pull all dotfiles for this host + saiden-config dotfiles pull --all --dry-run Preview pull without writing + saiden-config dotfiles sync [--push] Sync dotfiles (pull by default) + saiden-config dotfiles sync --push Push all local dotfiles to KV + saiden-config dotfiles diff [file] Compare local vs KV (all or one) + Environment: SAIDEN_CONFIG_API_KEY API key (overrides config file) SAIDEN_CONFIG_URL Base URL (default: https://config.saiden.dev)`); @@ -226,7 +238,7 @@ async function main() { process.exit(1); } - // --- Sync --- + // --- Sync (Coolify) --- if (command === "sync") { const project = args[1]; const env = args[2]; @@ -240,6 +252,73 @@ async function main() { return; } + // --- Dotfiles --- + if (command === "dotfiles") { + if (sub === "init") { + await dotfilesInit(client); + return; + } + + if (sub === "ls" || sub === "list") { + await dotfilesList(client); + return; + } + + if (sub === "push") { + const fileArg = args[2]; + if (hasFlag("--all")) { + await dotfilesPush(client, "", { all: true }); + } else if (fileArg) { + await dotfilesPush(client, fileArg, { + env: flag("--env"), + file: flag("-f"), + }); + } else { + console.error("Usage: saiden-config dotfiles push | --all"); + process.exit(1); + } + return; + } + + if (sub === "pull") { + const fileArg = args[2]; + if (hasFlag("--all")) { + await dotfilesPull(client, "", { + all: true, + dryRun: hasFlag("--dry-run"), + }); + } else if (fileArg) { + await dotfilesPull(client, fileArg, { + env: flag("--env"), + out: flag("--out"), + dryRun: hasFlag("--dry-run"), + }); + } else { + console.error("Usage: saiden-config dotfiles pull | --all [--dry-run]"); + process.exit(1); + } + return; + } + + if (sub === "sync") { + await dotfilesSync(client, { + host: flag("--host"), + dryRun: hasFlag("--dry-run"), + direction: hasFlag("--push") ? "push" : "pull", + }); + return; + } + + if (sub === "diff") { + const fileArg = args[2]; + await dotfilesDiff(client, fileArg, { env: flag("--env") }); + return; + } + + console.error("Unknown dotfiles command: " + sub); + process.exit(1); + } + usage(); } catch (e) { if (e instanceof ApiError) { diff --git a/src/dotfiles.ts b/src/dotfiles.ts new file mode 100644 index 0000000..d85ca75 --- /dev/null +++ b/src/dotfiles.ts @@ -0,0 +1,382 @@ +import { Client } from "./client"; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; +import { dirname, resolve } from "path"; +import { homedir } from "os"; +import { execSync } from "child_process"; + +const PROJECT = "dotfiles"; + +// Manifest: maps KV file path -> local filesystem paths per host +export interface DotfileEntry { + local: Record; // host -> absolute path +} + +export interface Manifest { + files: Record; +} + +function expandHome(p: string): string { + return p.replace(/^~/, homedir()); +} + +// Hostname aliases — map real hostnames to canonical names used in manifests +const HOST_ALIASES: Record = { + "fuji-2": "fuji", + "fuji-3": "fuji", +}; + +function getHostname(): string { + try { + const raw = execSync("hostname -s", { encoding: "utf-8" }).trim().toLowerCase(); + return HOST_ALIASES[raw] ?? raw; + } catch { + return "unknown"; + } +} + +// Default manifest — matches the layout we designed +export function defaultManifest(): Manifest { + return { + files: { + // Shared configs (identical on all hosts) + "starship.toml": { + local: { + fuji: "~/.config/starship.toml", + junkpile: "~/.config/starship.toml", + }, + }, + "vimrc": { + local: { + fuji: "~/.vimrc", + junkpile: "~/.vimrc", + }, + }, + "gitconfig": { + local: { + fuji: "~/.gitconfig", + junkpile: "~/.gitconfig", + }, + }, + "gitignore": { + local: { + fuji: "~/.gitignore", + junkpile: "~/.gitignore", + }, + }, + "kitty/kitty.conf": { + local: { + fuji: "~/.config/kitty/kitty.conf", + junkpile: "~/.config/kitty/kitty.conf", + }, + }, + "kitty/ssh.conf": { + local: { + fuji: "~/.config/kitty/ssh.conf", + junkpile: "~/.config/kitty/ssh.conf", + }, + }, + "aliases.zsh": { + local: { + fuji: "~/.dotfiles/aliases.zsh", + junkpile: "~/.dotfiles/aliases.zsh", + }, + }, + "functions.zsh": { + local: { + fuji: "~/.dotfiles/functions.zsh", + junkpile: "~/.dotfiles/functions.zsh", + }, + }, + "variables.zsh": { + local: { + fuji: "~/.dotfiles/variables.zsh", + junkpile: "~/.dotfiles/variables.zsh", + }, + }, + "zshrc": { + local: { + fuji: "~/.dotfiles/zshrc", + junkpile: "~/.dotfiles/zshrc", + }, + }, + + // Host-specific configs + "fish/config.fish": { + local: { + fuji: "~/.config/fish/config.fish", + }, + }, + "fish/conf.d/rustup.fish": { + local: { + fuji: "~/.config/fish/conf.d/rustup.fish", + }, + }, + "skhdrc": { + local: { + fuji: "~/.dotfiles/skhdrc", + }, + }, + }, + }; +} + +// Determine which env a file belongs to based on which hosts have it +function envForFile(entry: DotfileEntry): string { + const hosts = Object.keys(entry.local); + if (hosts.length > 1) return "shared"; + return hosts[0] ?? "shared"; +} + +// Get manifest from KV, or fall back to default +async function getManifest(client: Client): Promise { + try { + const raw = await client.getFile(PROJECT, "shared", "manifest.json"); + return JSON.parse(raw); + } catch { + return defaultManifest(); + } +} + +// Save manifest to KV +async function saveManifest(client: Client, manifest: Manifest): Promise { + await client.putFile(PROJECT, "shared", "manifest.json", JSON.stringify(manifest, null, 2), "application/json"); +} + +// ─── Commands ─────────────────────────────────────────────────────────────── + +export async function dotfilesList(client: Client): Promise { + const manifest = await getManifest(client); + const hostname = getHostname(); + + console.log(`Host: ${hostname}\n`); + console.log("File".padEnd(30) + "Env".padEnd(10) + "Local Path"); + console.log("─".repeat(80)); + + for (const [file, entry] of Object.entries(manifest.files)) { + const env = envForFile(entry); + const localPath = entry.local[hostname] ?? "(not on this host)"; + console.log(file.padEnd(30) + env.padEnd(10) + localPath); + } +} + +export async function dotfilesPush( + client: Client, + fileArg: string, + opts: { env?: string; file?: string; all?: boolean }, +): Promise { + const manifest = await getManifest(client); + const hostname = getHostname(); + + if (opts.all) { + // Push all files for this host + let count = 0; + for (const [file, entry] of Object.entries(manifest.files)) { + const localPath = entry.local[hostname]; + if (!localPath) continue; + + const expanded = expandHome(localPath); + if (!existsSync(expanded)) { + console.log(` skip ${file} (${expanded} not found)`); + continue; + } + + const env = envForFile(entry); + const content = readFileSync(expanded, "utf-8"); + await client.putFile(PROJECT, env, file, content); + console.log(` push ${file} -> ${PROJECT}/${env}/${file} (${content.length}b)`); + count++; + } + console.log(`\nPushed ${count} files.`); + + // Also save manifest + await saveManifest(client, manifest); + console.log("Manifest saved."); + return; + } + + // Single file push + const entry = manifest.files[fileArg]; + if (!entry) { + console.error(`Unknown dotfile: ${fileArg}`); + console.error("Known files: " + Object.keys(manifest.files).join(", ")); + process.exit(1); + } + + const env = opts.env ?? envForFile(entry); + const sourcePath = opts.file ?? entry.local[hostname]; + if (!sourcePath) { + console.error(`No local path for ${fileArg} on ${hostname}`); + process.exit(1); + } + + const expanded = expandHome(sourcePath); + if (!existsSync(expanded)) { + console.error(`File not found: ${expanded}`); + process.exit(1); + } + + const content = readFileSync(expanded, "utf-8"); + await client.putFile(PROJECT, env, fileArg, content); + console.log(`Pushed ${fileArg} (${content.length}b) -> ${PROJECT}/${env}/${fileArg}`); +} + +export async function dotfilesPull( + client: Client, + fileArg: string, + opts: { env?: string; out?: string; all?: boolean; dryRun?: boolean }, +): Promise { + const manifest = await getManifest(client); + const hostname = getHostname(); + + if (opts.all) { + // Pull all files relevant to this host + let count = 0; + for (const [file, entry] of Object.entries(manifest.files)) { + const localPath = entry.local[hostname]; + if (!localPath) continue; + + const env = envForFile(entry); + try { + const content = await client.getFile(PROJECT, env, file); + const expanded = expandHome(localPath); + + if (opts.dryRun) { + console.log(` would pull ${file} -> ${expanded} (${content.length}b)`); + } else { + mkdirSync(dirname(expanded), { recursive: true }); + writeFileSync(expanded, content); + console.log(` pull ${file} -> ${expanded} (${content.length}b)`); + } + count++; + } catch (e: any) { + if (e?.status === 404) { + console.log(` skip ${file} (not in KV)`); + } else { + throw e; + } + } + } + console.log(`\n${opts.dryRun ? "Would pull" : "Pulled"} ${count} files.`); + return; + } + + // Single file pull + const entry = manifest.files[fileArg]; + if (!entry) { + console.error(`Unknown dotfile: ${fileArg}`); + console.error("Known files: " + Object.keys(manifest.files).join(", ")); + process.exit(1); + } + + const env = opts.env ?? envForFile(entry); + const content = await client.getFile(PROJECT, env, fileArg); + const outPath = opts.out ?? entry.local[hostname]; + + if (!outPath) { + // No local path and no --out, just print to stdout + process.stdout.write(content); + return; + } + + const expanded = expandHome(outPath); + if (opts.dryRun) { + console.log(`Would write ${content.length}b to ${expanded}`); + return; + } + + mkdirSync(dirname(expanded), { recursive: true }); + writeFileSync(expanded, content); + console.log(`Pulled ${fileArg} (${content.length}b) -> ${expanded}`); +} + +export async function dotfilesSync( + client: Client, + opts: { host?: string; dryRun?: boolean; direction?: "pull" | "push" }, +): Promise { + const hostname = opts.host ?? getHostname(); + const direction = opts.direction ?? "pull"; + + console.log(`Syncing dotfiles for ${hostname} (${direction})...\n`); + + if (direction === "push") { + await dotfilesPush(client, "", { all: true }); + } else { + await dotfilesPull(client, "", { all: true, dryRun: opts.dryRun }); + } +} + +export async function dotfilesDiff( + client: Client, + fileArg: string, + opts: { env?: string }, +): Promise { + const manifest = await getManifest(client); + const hostname = getHostname(); + + const filesToDiff = fileArg + ? [[fileArg, manifest.files[fileArg]] as const] + : Object.entries(manifest.files); + + if (fileArg && !manifest.files[fileArg]) { + console.error(`Unknown dotfile: ${fileArg}`); + process.exit(1); + } + + for (const [file, entry] of filesToDiff) { + if (!entry) continue; + const localPath = entry.local[hostname]; + if (!localPath) continue; + + const expanded = expandHome(localPath); + const env = opts.env ?? envForFile(entry); + + let remote: string; + try { + remote = await client.getFile(PROJECT, env, file); + } catch (e: any) { + if (e?.status === 404) { + console.log(`${file}: not in KV`); + continue; + } + throw e; + } + + if (!existsSync(expanded)) { + console.log(`${file}: local file missing (${expanded})`); + continue; + } + + const local = readFileSync(expanded, "utf-8"); + + if (local === remote) { + console.log(`${file}: identical`); + } else { + console.log(`${file}: DIFFERS`); + + // Simple line-by-line diff + const localLines = local.split("\n"); + const remoteLines = remote.split("\n"); + const maxLines = Math.max(localLines.length, remoteLines.length); + + for (let i = 0; i < maxLines; i++) { + if (localLines[i] !== remoteLines[i]) { + if (remoteLines[i] !== undefined) { + console.log(` - ${i + 1}: ${remoteLines[i]}`); + } + if (localLines[i] !== undefined) { + console.log(` + ${i + 1}: ${localLines[i]}`); + } + } + } + console.log(); + } + } +} + +export async function dotfilesInit(client: Client): Promise { + const manifest = defaultManifest(); + await saveManifest(client, manifest); + console.log("Default manifest saved to KV."); + console.log(`Registered ${Object.keys(manifest.files).length} files.`); + console.log("\nNext: saiden-config dotfiles push --all"); +}