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:
+1
-1
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user