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",
|
"name": "@madcat-os/saiden-config",
|
||||||
"version": "0.1.5",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"saiden-config": "bin/saiden-config.js"
|
"saiden-config": "bin/saiden-config.js"
|
||||||
|
|||||||
+80
-1
@@ -3,6 +3,7 @@
|
|||||||
import { loadConfig, saveConfig, ensureApiKey } from "./config";
|
import { loadConfig, saveConfig, ensureApiKey } from "./config";
|
||||||
import { Client, ApiError } from "./client";
|
import { Client, ApiError } from "./client";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
|
import { dotfilesList, dotfilesPush, dotfilesPull, dotfilesSync, dotfilesDiff, dotfilesInit } from "./dotfiles";
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const command = args[0];
|
const command = args[0];
|
||||||
@@ -35,6 +36,17 @@ Usage:
|
|||||||
|
|
||||||
saiden-config sync <project> <env>
|
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:
|
Environment:
|
||||||
SAIDEN_CONFIG_API_KEY API key (overrides config file)
|
SAIDEN_CONFIG_API_KEY API key (overrides config file)
|
||||||
SAIDEN_CONFIG_URL Base URL (default: https://config.saiden.dev)`);
|
SAIDEN_CONFIG_URL Base URL (default: https://config.saiden.dev)`);
|
||||||
@@ -226,7 +238,7 @@ async function main() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sync ---
|
// --- Sync (Coolify) ---
|
||||||
if (command === "sync") {
|
if (command === "sync") {
|
||||||
const project = args[1];
|
const project = args[1];
|
||||||
const env = args[2];
|
const env = args[2];
|
||||||
@@ -240,6 +252,73 @@ async function main() {
|
|||||||
return;
|
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();
|
usage();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError) {
|
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