Add dotfiles management: push/pull/sync/diff/ls with KV-backed manifest

- New dotfiles subcommand with init, ls, push, pull, sync, diff
- Manifest-driven: maps KV paths to local filesystem per host
- Host aliasing (fuji-2 -> fuji) for flexible hostname matching
- Supports shared (cross-machine) and host-specific configs
- 13 files tracked: fish, starship, vim, kitty, git, zsh, skhd
- Bump to v0.2.0
This commit is contained in:
marauder-actual
2026-06-10 18:29:59 +02:00
parent 9b2f9bdef2
commit 69014b463f
3 changed files with 463 additions and 2 deletions
+1 -1
View File
@@ -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"
+80 -1
View File
@@ -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 <project> <env>
saiden-config dotfiles init Upload default manifest to KV
saiden-config dotfiles ls List all tracked dotfiles
saiden-config dotfiles push <file> Push a local dotfile to KV
saiden-config dotfiles push --all Push all dotfiles for this host
saiden-config dotfiles pull <file> 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 <file> | --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 <file> | --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) {
+382
View File
@@ -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<string, string>; // host -> absolute path
}
export interface Manifest {
files: Record<string, DotfileEntry>;
}
function expandHome(p: string): string {
return p.replace(/^~/, homedir());
}
// Hostname aliases — map real hostnames to canonical names used in manifests
const HOST_ALIASES: Record<string, string> = {
"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<Manifest> {
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<void> {
await client.putFile(PROJECT, "shared", "manifest.json", JSON.stringify(manifest, null, 2), "application/json");
}
// ─── Commands ───────────────────────────────────────────────────────────────
export async function dotfilesList(client: Client): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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");
}