diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..562e582 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.tgz +.DS_Store +.env* diff --git a/Plans/dotfiles-bare-repo-guide.md b/.prd/dotfiles-bare-repo-guide.md similarity index 96% rename from Plans/dotfiles-bare-repo-guide.md rename to .prd/dotfiles-bare-repo-guide.md index a3b0a1d..eb86483 100644 --- a/Plans/dotfiles-bare-repo-guide.md +++ b/.prd/dotfiles-bare-repo-guide.md @@ -34,6 +34,12 @@ dotfiles push -u origin main ```bash # One command — clones, backs up conflicts, checks out, configures +dotfiles bootstrap YOUR_USERNAME + +# Or with a custom repo name +dotfiles bootstrap YOUR_USERNAME/my-dots + +# Full URLs also work dotfiles bootstrap git@github.com:YOUR_USERNAME/dotfiles.git ``` @@ -63,11 +69,11 @@ graph LR | Command | What It Does | |---------|-------------| | `dotfiles help` | Show all commands | -| `dotfiles init` | Create bare repo, propose initial files to track, add + commit | +| `dotfiles init` | Create bare repo, propose initial files to track, set up GitHub remote | | `dotfiles sync [message]` | Pull + stage tracked changes + commit + push. Default message: `sync YYYY-MM-DD` | | `dotfiles ls` | List all tracked files | | `dotfiles audit` | Scan tracked files for secrets (private keys, .env, API tokens) | -| `dotfiles bootstrap ` | Full new-machine setup: clone, backup conflicts, checkout, configure | +| `dotfiles bootstrap [/repo]` | Full new-machine setup: clone, backup conflicts, checkout, configure. Accepts username, user/repo, or full URL. | | `dotfiles add ` | Add file to tracking (warns on directories, shows dry-run first) | | `dotfiles diff` | Smart diff: fetches remote, shows local changes + incoming changes + diverged files | | `dotfiles merge` | Interactive per-file resolution of diverged (conflicting) files | @@ -463,12 +469,34 @@ Found these config files — select which to track: **Scan list:** The CLI checks for a built-in list of common dotfile paths. Files that exist are pre-selected (`[x]`). Files that don't exist are shown as `(not found)` and deselected. The user toggles with arrow keys + space, then confirms. -**After selection:** Selected files are added and committed as `"Initial dotfiles"`. The user is then prompted to add a remote: +**After selection:** Selected files are added and committed as `"Initial dotfiles"`. The user is then guided through remote setup: + +**If GitHub CLI (`gh`) is installed and authenticated:** + +``` +Added 9 files. + +GitHub CLI detected. Set up a remote? + [c] Create new private repo on GitHub + [e] Enter an existing repo URL + [s] Skip for now + +> c +Repo name (default: dotfiles): dotfiles + +Created dotfiles on GitHub and added as remote. +Run "dotfiles push -u origin main" to push. +``` + +**If `gh` is not available:** ``` Added 9 files. -Add a remote? Enter repo URL (or press Enter to skip): +Add a remote to sync across machines. +Tip: Install GitHub CLI (gh) for guided repo creation. + +Enter repo URL (or press Enter to skip): > git@github.com:virtualian/dotfiles.git Remote added. Run "dotfiles push -u origin main" to push. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..85d354a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/). + +## [Unreleased] + +## [1.1.0] - 2026-02-23 + +### Added + +- Guided GitHub repo creation in `dotfiles init` — detects `gh` CLI, offers to create a private repo +- Bootstrap shorthand: `dotfiles bootstrap ` resolves to `git@github.com:user/dotfiles.git` +- Bootstrap also accepts `user/repo` shorthand for custom repo names +- README, LICENSE, CONTRIBUTING, and .gitignore + +### Changed + +- `dotfiles init` remote prompt now offers create/enter/skip when `gh` is available +- `dotfiles init` shows install tip when `gh` is not available +- `dotfiles bootstrap` usage message shows shorthand examples + +## [1.0.0] - 2026-02-23 + +### Added + +- `dotfiles init` — create bare repo, propose initial files to track +- `dotfiles sync [message]` — pull + commit tracked changes + push +- `dotfiles ls` — list all tracked files +- `dotfiles audit` — scan tracked files for secrets +- `dotfiles bootstrap ` — clone repo + set up new machine +- `dotfiles add ` — add file or directory to tracking +- `dotfiles diff` — smart diff (local + remote + diverged) +- `dotfiles merge` — resolve diverged files interactively +- `dotfiles help` — show help message +- Git pass-through for all standard git commands diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..916d033 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,81 @@ +# Contributing + +## Prerequisites + +- [Bun](https://bun.sh) >= 1.0 + +## Dev Setup + +```bash +git clone https://github.com/virtualian/dotfiles-cli.git +cd dotfiles-cli +bun install +bun test +``` + +## Project Structure + +``` +src/ + cli.ts # Entry point — dispatches subcommands or forwards to git + commands/ # One file per custom command (init, sync, diff, merge, audit, bootstrap, add, ls, help). init detects gh CLI for guided GitHub repo creation. + git.ts # Git wrapper — handles --git-dir and --work-tree flags +bin/ + dotfiles # Executable shim (#!/usr/bin/env bun) +tests/ + *.test.ts # Contract, pattern, and functional tests +``` + +## Testing + +```bash +bun test +``` + +The test suite covers three layers: + +- **Contract tests** — verify modules export the right shapes (`run(args): Promise`, dispatcher entries, git wrapper exports) +- **Pattern tests** — verify detection logic in isolation (audit filename patterns, content regexes, negative cases) +- **Functional tests** — execute commands and check output (`help` prints all 9 commands, `gitRaw` runs real git) + +Commands that require a bare repo or interactive stdin (`init`, `sync`, `bootstrap`, `diff`, `merge`, `audit`) are tested manually. + +## Pull Requests + +- One concern per PR +- Include a test plan for interactive commands (what you ran, what you saw) +- Run `bun test` before opening +- Keep changes focused — don't refactor unrelated code + +## Releases + +This project uses [semantic versioning](https://semver.org/) with git tags. + +### When to release + +- **Patch** (1.0.1) — bug fixes, typo corrections, no behavior change +- **Minor** (1.1.0) — new features, new commands, non-breaking enhancements +- **Major** (2.0.0) — breaking changes to command syntax or behavior + +### How to release + +```bash +bun run release patch # 1.1.0 -> 1.1.1 (bug fixes) +bun run release minor # 1.1.0 -> 1.2.0 (new features) +bun run release major # 1.1.0 -> 2.0.0 (breaking changes) +``` + +The release script (`scripts/release.ts`) handles everything: + +1. Computes the next version from the current `package.json` version +2. Checks for a clean working tree +3. Verifies the `## [Unreleased]` section has content +4. Moves Unreleased entries under a new versioned header +5. Bumps `"version"` in `package.json` +6. Commits, tags, and pushes with tags + +After the script finishes, publish to npm: + +```bash +bun publish --access public +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..013d83e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ian Marr + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c4270e --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# @virtualian/dotfiles-cli + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Bun](https://img.shields.io/badge/Bun-%3E%3D1.0-f9f1e1)](https://bun.sh) + +Sync shell and application configuration across machines using a bare git repository. + +## Quick Start + +```bash +# Install +bun add -g @virtualian/dotfiles-cli + +# First machine — initialize, pick files, create GitHub repo, push +dotfiles init +dotfiles push -u origin main + +# New machine — one command sets everything up +dotfiles bootstrap YOU + +# Daily workflow +dotfiles sync +``` + +Three commands to set up. One command to sync. + +## Command Reference + +| Command | Description | +|---------|-------------| +| `dotfiles help` | Show help message | +| `dotfiles init` | Create bare repo, propose files, set up GitHub remote | +| `dotfiles sync [message]` | Pull + commit tracked changes + push | +| `dotfiles ls` | List all tracked files | +| `dotfiles audit` | Scan tracked files for secrets | +| `dotfiles bootstrap ` | Clone repo + set up new machine (accepts `user`, `user/repo`, or full URL) | +| `dotfiles add ` | Add file or directory to tracking | +| `dotfiles diff` | Smart diff (local + remote + diverged) | +| `dotfiles merge` | Resolve diverged files interactively | + +Any unrecognized command is forwarded to git with bare repo flags — `dotfiles status`, `dotfiles log --oneline`, etc. all work as expected. + +## How It Works + +The CLI wraps the **bare git repository** technique: a git repo at `~/.dotfiles/` stores history while `$HOME` serves as the working tree. Every command translates to `git --git-dir=$HOME/.dotfiles/ --work-tree=$HOME `. Since the working tree is your entire home directory, `status.showUntrackedFiles no` keeps output clean — you explicitly add only what you want to track. + +For the full guide (what to track, sensitive data handling, machine-specific configs, troubleshooting), see [`.prd/dotfiles-bare-repo-guide.md`](.prd/dotfiles-bare-repo-guide.md). + +## Development + +```bash +git clone https://github.com/virtualian/dotfiles-cli.git +cd dotfiles-cli +bun install +bun test +``` + +### Architecture + +``` +src/ + cli.ts # Entry point + dispatcher + commands/ + init.ts # Create bare repo, propose files, set up remote + sync.ts # Pull + commit + push + diff.ts # Smart diff (fetch + 3-section display) + merge.ts # Interactive conflict resolution + audit.ts # Secret scanning + bootstrap.ts # New machine setup + add.ts # Add files to tracking + ls.ts # List tracked files + help.ts # Help output + git.ts # Git wrapper (--git-dir, --work-tree) +bin/ + dotfiles # Shim: #!/usr/bin/env bun +``` + +The CLI is a pass-through dispatcher: known subcommands are handled in `src/commands/`, everything else forwards to git. + +## License + +[MIT](LICENSE) — Ian Marr diff --git a/bin/dotfiles b/bin/dotfiles new file mode 100755 index 0000000..ca9e57c --- /dev/null +++ b/bin/dotfiles @@ -0,0 +1,2 @@ +#!/usr/bin/env bun +import "../src/cli.ts"; diff --git a/package.json b/package.json new file mode 100644 index 0000000..2318f38 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "@virtualian/dotfiles-cli", + "version": "1.0.0", + "type": "module", + "description": "Sync shell and application configuration across machines using a bare git repository", + "bin": { + "dotfiles": "./bin/dotfiles" + }, + "scripts": { + "test": "bun test", + "release": "bun scripts/release.ts" + }, + "files": [ + "bin", + "src" + ], + "keywords": [ + "dotfiles", + "git", + "bare-repo", + "sync", + "cli" + ], + "author": "Ian Marr", + "license": "MIT", + "engines": { + "bun": ">=1.0.0" + } +} diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..fe42c0b --- /dev/null +++ b/scripts/release.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env bun + +/** + * Release script — automates version bumping, changelog update, commit, tag, and push. + * + * Usage: bun run release + */ + +import { $ } from "bun"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = join(import.meta.dir, ".."); + +// --- Helpers --- + +function die(msg: string): never { + console.error(`Error: ${msg}`); + process.exit(1); +} + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +type BumpType = "major" | "minor" | "patch"; + +function bumpVersion(current: string, type: BumpType): string { + const [major, minor, patch] = current.split(".").map(Number); + switch (type) { + case "major": return `${major + 1}.0.0`; + case "minor": return `${major}.${minor + 1}.0`; + case "patch": return `${major}.${minor}.${patch + 1}`; + } +} + +// --- Validation --- + +const bumpType = process.argv[2] as BumpType | undefined; +const validTypes: BumpType[] = ["major", "minor", "patch"]; + +if (!bumpType || !validTypes.includes(bumpType)) { + die("Usage: bun run release "); +} + +// Read current version +const pkgPath = join(ROOT, "package.json"); +const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); +const oldVersion: string = pkg.version; +const version = bumpVersion(oldVersion, bumpType); + +// Check for clean working tree +const status = await $`git -C ${ROOT} status --porcelain`.quiet().text(); +if (status.trim()) { + die("Working tree is dirty. Commit or stash changes before releasing."); +} + +// --- Read and validate CHANGELOG --- + +const changelogPath = join(ROOT, "CHANGELOG.md"); +const changelog = readFileSync(changelogPath, "utf-8"); + +const unreleasedMatch = changelog.match(/## \[Unreleased\]\n([\s\S]*?)(?=\n## \[)/); +if (!unreleasedMatch) { + die("Could not find ## [Unreleased] section in CHANGELOG.md"); +} + +const unreleasedContent = unreleasedMatch[1].trim(); +if (!unreleasedContent) { + die("## [Unreleased] section is empty. Add changelog entries before releasing."); +} + +if (changelog.includes(`## [${version}]`)) { + die(`Version ${version} already exists in CHANGELOG.md`); +} + +// --- Update CHANGELOG --- + +const newChangelog = changelog.replace( + `## [Unreleased]\n${unreleasedMatch[1]}`, + `## [Unreleased]\n\n## [${version}] - ${today()}\n${unreleasedMatch[1]}` +); +writeFileSync(changelogPath, newChangelog); + +// --- Update package.json --- + +pkg.version = version; +writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + +// --- Git commit, tag, push --- + +console.log(`\nReleasing v${version} (${bumpType} bump)`); +console.log(` package.json: ${oldVersion} -> ${version}`); +console.log(` CHANGELOG.md: [Unreleased] -> [${version}] - ${today()}`); +console.log(); + +await $`git -C ${ROOT} add CHANGELOG.md package.json`; +await $`git -C ${ROOT} commit -m ${"release: v" + version}`; +await $`git -C ${ROOT} tag ${"v" + version}`; +await $`git -C ${ROOT} push origin main --tags`; + +console.log(`\nDone. v${version} tagged and pushed.`); +console.log(`Run "bun publish --access public" to publish to npm.`); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..0ddf8f0 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,45 @@ +import { gitPassthrough } from "./git.ts"; + +const COMMANDS: Record Promise<{ run: (args: string[]) => Promise }>> = { + help: () => import("./commands/help.ts"), + init: () => import("./commands/init.ts"), + sync: () => import("./commands/sync.ts"), + ls: () => import("./commands/ls.ts"), + audit: () => import("./commands/audit.ts"), + bootstrap: () => import("./commands/bootstrap.ts"), + add: () => import("./commands/add.ts"), + diff: () => import("./commands/diff.ts"), + merge: () => import("./commands/merge.ts"), +}; + +async function main(): Promise { + const args = process.argv.slice(2); + const command = args[0]; + const restArgs = args.slice(1); + + if (!command || command === "--help" || command === "-h") { + const mod = await COMMANDS.help(); + process.exit(await mod.run([])); + } + + if (command in COMMANDS) { + // Special case: "diff" with args passes through to git + if (command === "diff" && restArgs.length > 0) { + const exitCode = await gitPassthrough("diff", ...restArgs); + process.exit(exitCode); + } + + const mod = await COMMANDS[command](); + const exitCode = await mod.run(restArgs); + process.exit(exitCode); + } + + // Pass-through to git + const exitCode = await gitPassthrough(...args); + process.exit(exitCode); +} + +main().catch((err) => { + console.error(err.message ?? err); + process.exit(1); +}); diff --git a/src/commands/add.ts b/src/commands/add.ts new file mode 100644 index 0000000..317a7e4 --- /dev/null +++ b/src/commands/add.ts @@ -0,0 +1,51 @@ +import { gitCapture, gitPassthrough } from "../git.ts"; +import { createInterface } from "node:readline"; +import { stat } from "node:fs/promises"; + +async function confirm(prompt: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(prompt, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === "y"); + }); + }); +} + +export async function run(args: string[]): Promise { + const path = args[0]; + if (!path) { + console.error("Usage: dotfiles add "); + return 1; + } + + // Check if path is a directory + let isDir = false; + try { + const info = await stat(path); + isDir = info.isDirectory(); + } catch { + // Path doesn't exist or not accessible - let git handle the error + } + + if (isDir) { + // Show dry-run preview + console.log(`Directory detected. Previewing files that would be added:\n`); + const dryRun = await gitCapture("add", "-n", path); + if (dryRun.stdout) { + console.log(dryRun.stdout); + } + if (dryRun.stderr) { + console.log(dryRun.stderr); + } + console.log(); + + const ok = await confirm("Add these files? [y/N] "); + if (!ok) { + console.log("Aborted."); + return 0; + } + } + + return gitPassthrough("add", path); +} diff --git a/src/commands/audit.ts b/src/commands/audit.ts new file mode 100644 index 0000000..283c437 --- /dev/null +++ b/src/commands/audit.ts @@ -0,0 +1,68 @@ +import { gitCapture, WORK_TREE } from "../git.ts"; +import { join } from "node:path"; + +const SUSPICIOUS_NAMES = [ + /\.pem$/, + /\.key$/, + /id_rsa$/, + /id_ed25519$/, + /^\.env$/, + /^\.env\..+/, +]; + +const SUSPICIOUS_CONTENT = [ + /api[_-]?key\s*[=:]/i, + /secret\s*[=:]/i, + /token\s*[=:]/i, + /password\s*[=:]/i, + /private[_-]?key/i, +]; + +export async function run(_args: string[]): Promise { + const result = await gitCapture("ls-files"); + if (!result.stdout) { + console.log("No tracked files."); + return 0; + } + + const files = result.stdout.split("\n").filter(Boolean); + const issues: string[] = []; + + for (const file of files) { + const basename = file.split("/").pop() ?? file; + + // Check filename patterns + for (const pattern of SUSPICIOUS_NAMES) { + if (pattern.test(basename)) { + issues.push(` [name] ${file} (matches ${pattern})`); + break; + } + } + + // Check file contents + const fullPath = join(WORK_TREE, file); + try { + const content = await Bun.file(fullPath).text(); + for (const pattern of SUSPICIOUS_CONTENT) { + if (pattern.test(content)) { + issues.push(` [content] ${file} (matches ${pattern})`); + break; + } + } + } catch { + // File may not be readable, skip + } + } + + if (issues.length === 0) { + console.log("Audit clean. No secrets detected in tracked files."); + return 0; + } + + console.log(`Audit found ${issues.length} potential issue(s):\n`); + for (const issue of issues) { + console.log(issue); + } + console.log("\nReview these files and remove secrets before pushing."); + return 1; +} diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts new file mode 100644 index 0000000..44f1a48 --- /dev/null +++ b/src/commands/bootstrap.ts @@ -0,0 +1,104 @@ +import { gitCapture, gitRaw, HOME, GIT_DIR } from "../git.ts"; +import { existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** Resolve shorthand to full git URL */ +function resolveRepoUrl(input: string): string { + // Full URLs pass through unchanged + if (input.includes("://") || input.includes("git@") || input.startsWith("/")) { + return input; + } + // user/repo → git@github.com:user/repo.git + if (input.includes("/")) { + return `git@github.com:${input}.git`; + } + // bare username → git@github.com:user/dotfiles.git + return `git@github.com:${input}/dotfiles.git`; +} + +export async function run(args: string[]): Promise { + // 1. Require repo identifier + const input = args[0]; + if (!input) { + console.error("Usage: dotfiles bootstrap [/repo]"); + console.error("Examples:"); + console.error(" dotfiles bootstrap virtualian # github.com:virtualian/dotfiles.git"); + console.error(" dotfiles bootstrap virtualian/my-dots # github.com:virtualian/my-dots.git"); + console.error(" dotfiles bootstrap git@github.com:u/r.git # full URL"); + return 1; + } + + const repoUrl = resolveRepoUrl(input); + + // 2. Check if ~/.dotfiles already exists + if (existsSync(GIT_DIR)) { + console.error(`Error: ${GIT_DIR} already exists. Remove it first or use a different approach.`); + return 1; + } + + // 3. Clone bare repo (use gitRaw since the bare repo doesn't exist yet) + console.log(`Cloning ${repoUrl} into ${GIT_DIR}...`); + const cloneResult = await gitRaw("clone", "--bare", repoUrl, GIT_DIR); + if (cloneResult.exitCode !== 0) { + console.error(`Clone failed: ${cloneResult.stderr}`); + return 1; + } + + // 4. Attempt checkout + console.log("Checking out files..."); + const checkoutResult = await gitCapture("checkout"); + + if (checkoutResult.exitCode !== 0) { + // 5. Parse conflicting files from stderr + const stderr = checkoutResult.stderr; + const conflictLines = stderr.split("\n").filter((line) => line.startsWith("\t")).map((line) => line.trim()); + + if (conflictLines.length === 0) { + console.error(`Checkout failed: ${stderr}`); + return 1; + } + + // Create backup directory + const timestamp = new Date().toISOString().replace(/[:.T]/g, "").slice(0, 15); + const backupDir = `${HOME}/.dotfiles-backup-${timestamp}`; + + console.log(`\nBacking up ${conflictLines.length} conflicting files to ${backupDir}/`); + + for (const file of conflictLines) { + const srcPath = join(HOME, file); + const destPath = join(backupDir, file); + const destDir = dirname(destPath); + + if (!existsSync(destDir)) { + mkdirSync(destDir, { recursive: true }); + } + + if (existsSync(srcPath)) { + // Move the conflicting file to backup + const content = await Bun.file(srcPath).bytes(); + await Bun.write(destPath, content); + const { unlinkSync } = await import("node:fs"); + unlinkSync(srcPath); + } + } + + // 6. Retry checkout + console.log("Retrying checkout..."); + const retryResult = await gitCapture("checkout"); + if (retryResult.exitCode !== 0) { + console.error(`Checkout still failed after backup: ${retryResult.stderr}`); + return 1; + } + } + + // 7. Set config: hide untracked files + const configResult = await gitCapture("config", "--local", "status.showUntrackedFiles", "no"); + if (configResult.exitCode !== 0) { + console.error(`Warning: failed to set git config: ${configResult.stderr}`); + } + + console.log("\nBootstrap complete. Your dotfiles are ready."); + console.log('Run "dotfiles status" to see tracked files.'); + + return 0; +} diff --git a/src/commands/diff.ts b/src/commands/diff.ts new file mode 100644 index 0000000..be6dbea --- /dev/null +++ b/src/commands/diff.ts @@ -0,0 +1,132 @@ +import { gitCapture } from "../git.ts"; + +// ANSI color helpers +const BOLD = "\x1b[1m"; +const RESET = "\x1b[0m"; +const YELLOW = "\x1b[33m"; +const GREEN = "\x1b[32m"; +const RED = "\x1b[31m"; +const DIM = "\x1b[2m"; + +function statusColor(status: string): string { + switch (status) { + case "M": + return YELLOW; + case "A": + return GREEN; + case "D": + return RED; + default: + return RESET; + } +} + +interface FileChange { + status: string; + file: string; +} + +function parseNameStatus(output: string): FileChange[] { + if (!output.trim()) return []; + return output + .trim() + .split("\n") + .map((line) => { + const [status, ...rest] = line.trim().split(/\s+/); + return { status: status ?? "", file: rest.join(" ") }; + }) + .filter((entry) => entry.status && entry.file); +} + +function printSection(header: string, files: FileChange[]): void { + const count = files.length; + const label = count === 1 ? "file" : "files"; + console.log(`${BOLD}${header}${RESET} ${DIM}${count} ${label}${RESET}`); + for (const { status, file } of files) { + const color = statusColor(status); + console.log(` ${color}${status}${RESET} ${file}`); + } + console.log(); +} + +export async function run(_args: string[]): Promise { + // Step a: Fetch origin silently (ignore errors = offline) + const fetchResult = await gitCapture("fetch", "origin"); + const isOffline = fetchResult.exitCode !== 0; + + // Step b: Get local changes (working tree vs HEAD) + const localResult = await gitCapture("diff", "--name-status"); + const localChanges = parseNameStatus(localResult.stdout); + + // Step c: Get incoming changes (remote has but local doesn't) + let incomingChanges: FileChange[] = []; + if (!isOffline) { + const incomingResult = await gitCapture( + "diff", + "HEAD..origin/main", + "--name-status" + ); + if (incomingResult.exitCode === 0) { + incomingChanges = parseNameStatus(incomingResult.stdout); + } + } + + // Step d: Calculate diverged — files appearing in BOTH local and incoming + const localFiles = new Set(localChanges.map((c) => c.file)); + const diverged = incomingChanges.filter((c) => localFiles.has(c.file)); + const divergedFiles = new Set(diverged.map((c) => c.file)); + + // Remove diverged files from local and incoming for display + const localOnly = localChanges.filter((c) => !divergedFiles.has(c.file)); + const incomingOnly = incomingChanges.filter( + (c) => !divergedFiles.has(c.file) + ); + + // Step g: If ALL sections empty + if ( + localOnly.length === 0 && + incomingOnly.length === 0 && + diverged.length === 0 + ) { + console.log("Everything in sync."); + return 0; + } + + // Step h: Offline warning + if (isOffline) { + console.log( + `${YELLOW}⚠ Could not reach remote — showing local changes only${RESET}\n` + ); + } + + // Step e: Display sections (step f: hide empty ones) + console.log("─────────────────────────────────────\n"); + + if (localOnly.length > 0) { + printSection("📍 Local Changes (uncommitted)", localOnly); + } + + if (incomingOnly.length > 0) { + printSection("📥 Incoming (remote → local)", incomingOnly); + } + + if (diverged.length > 0) { + printSection("⚡ Diverged (both changed)", diverged); + } + + console.log("─────────────────────────────────────"); + + // Step j: Summary line + console.log( + `${DIM}Summary: ${localOnly.length} local -- ${incomingOnly.length} incoming -- ${diverged.length} diverged${RESET}` + ); + + // Step i: Tip for diverged files + if (diverged.length > 0) { + console.log( + `\n${DIM}Tip: Run "dotfiles merge" to resolve diverged files.${RESET}` + ); + } + + return 0; +} diff --git a/src/commands/help.ts b/src/commands/help.ts new file mode 100644 index 0000000..5e76d5a --- /dev/null +++ b/src/commands/help.ts @@ -0,0 +1,29 @@ +export async function run(_args: string[]): Promise { + const text = ` +dotfiles - manage dotfiles via bare git repo + +Custom Commands: + help Show this help message + init Create bare repo, propose files, set up GitHub remote + sync [message] Pull + commit tracked changes + push + ls List all tracked files + audit Scan tracked files for secrets + bootstrap Clone repo + set up new machine (or user/repo, or full URL) + add Add file or directory to tracking + diff Smart diff (local + remote + diverged) + merge Resolve diverged files interactively + +Git Pass-Through (all standard git commands work): + dotfiles status What's changed + dotfiles log --oneline Commit history + dotfiles stash Stash changes + dotfiles branch -a List branches + dotfiles remote -v Show remotes + dotfiles rm --cached Stop tracking without deleting + +Any unrecognized command is forwarded to git with bare repo flags. +`.trim(); + + console.log(text); + return 0; +} diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..ffa7146 --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,262 @@ +import { gitCapture, HOME, GIT_DIR } from "../git.ts"; +import { createInterface } from "node:readline"; +import { existsSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +const EXCLUDE_PATTERNS = `# Private keys +.ssh/id_* +*.pem +*.key + +# Secrets +.env +.env.* + +# Claude Code ephemeral/sensitive +.claude/history.jsonl +.claude/debug/ +.claude/cache/ +.claude/settings.json +.claude/telemetry/ +.claude/todos/ + +# macOS cruft +.DS_Store +._* + +# Shell history and compiled state +.zsh_history +.zcompdump* +`; + +interface FileEntry { + path: string; + label: string; + exists: boolean; + selected: boolean; +} + +interface CategoryEntry { + name: string; + files: FileEntry[]; +} + +function buildFileList(): CategoryEntry[] { + const categories: CategoryEntry[] = [ + { + name: "Shell", + files: [ + { path: `${HOME}/.zshrc`, label: "~/.zshrc", exists: false, selected: false }, + { path: `${HOME}/.zshenv`, label: "~/.zshenv", exists: false, selected: false }, + { path: `${HOME}/.bashrc`, label: "~/.bashrc", exists: false, selected: false }, + ], + }, + { + name: "Git", + files: [ + { path: `${HOME}/.gitconfig`, label: "~/.gitconfig", exists: false, selected: false }, + ], + }, + { + name: "SSH", + files: [ + { path: `${HOME}/.ssh/config`, label: "~/.ssh/config", exists: false, selected: false }, + ], + }, + { + name: "Claude Code", + files: [ + { path: `${HOME}/.claude/CLAUDE.md`, label: "~/.claude/CLAUDE.md", exists: false, selected: false }, + { path: `${HOME}/.claude/agents`, label: "~/.claude/agents/", exists: false, selected: false }, + { path: `${HOME}/.claude/hooks`, label: "~/.claude/hooks/", exists: false, selected: false }, + { path: `${HOME}/.claude/skills`, label: "~/.claude/skills/", exists: false, selected: false }, + { path: `${HOME}/.claude/MEMORY`, label: "~/.claude/MEMORY/", exists: false, selected: false }, + ], + }, + { + name: "Other", + files: [ + { path: `${HOME}/.vimrc`, label: "~/.vimrc", exists: false, selected: false }, + { path: `${HOME}/.config/starship.toml`, label: "~/.config/starship.toml", exists: false, selected: false }, + ], + }, + ]; + + for (const cat of categories) { + for (const f of cat.files) { + f.exists = existsSync(f.path); + f.selected = f.exists; + } + } + + return categories; +} + +function printFileList(categories: CategoryEntry[]): void { + console.log("\nFound these config files -- select which to track:\n"); + for (const cat of categories) { + console.log(` ${cat.name}`); + for (const f of cat.files) { + const check = f.selected ? "[x]" : "[ ]"; + const suffix = f.exists ? "" : " (not found)"; + console.log(` ${check} ${f.label}${suffix}`); + } + console.log(); + } + console.log("[a] Add all [n] Add none [Enter] Accept selected"); +} + +function ask(rl: ReturnType, prompt: string): Promise { + return new Promise((resolve) => { + rl.question(prompt, (answer) => resolve(answer.trim())); + }); +} + +async function isGhReady(): Promise { + const version = await Bun.$`gh --version`.quiet().nothrow(); + if (version.exitCode !== 0) return false; + const auth = await Bun.$`gh auth status`.quiet().nothrow(); + return auth.exitCode === 0; +} + +export async function run(_args: string[]): Promise { + // Check if bare repo already exists + if (existsSync(GIT_DIR)) { + console.error(`Error: ${GIT_DIR} already exists. Dotfiles repo is already initialized.`); + return 1; + } + + // 1. Create bare repo + const initResult = await Bun.$`git init --bare ${GIT_DIR}`.quiet().nothrow(); + if (initResult.exitCode !== 0) { + console.error(`Failed to create bare repo: ${initResult.stderr.toString().trim()}`); + return 1; + } + console.log(`Created bare repo at ~/.dotfiles`); + + // 2. Write exclude patterns + const excludePath = `${GIT_DIR}/info/exclude`; + const infoDir = dirname(excludePath); + if (!existsSync(infoDir)) { + mkdirSync(infoDir, { recursive: true }); + } + await Bun.write(excludePath, EXCLUDE_PATTERNS); + + // 3. Set config: hide untracked files + const configResult = await gitCapture("config", "--local", "status.showUntrackedFiles", "no"); + if (configResult.exitCode !== 0) { + console.error(`Failed to set git config: ${configResult.stderr}`); + return 1; + } + + // 4. Scan for common dotfiles and present checklist + const categories = buildFileList(); + printFileList(categories); + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + try { + const choice = await ask(rl, "> "); + + if (choice.toLowerCase() === "a") { + // Select all that exist + for (const cat of categories) { + for (const f of cat.files) { + if (f.exists) f.selected = true; + } + } + } else if (choice.toLowerCase() === "n") { + for (const cat of categories) { + for (const f of cat.files) { + f.selected = false; + } + } + } + // else: keep defaults (existing files pre-selected) + + // Gather selected files + const selected = categories.flatMap((c) => c.files).filter((f) => f.selected && f.exists); + + if (selected.length === 0) { + console.log("\nNo files selected. You can add files later with: dotfiles add "); + } else { + // Add selected files + for (const f of selected) { + await gitCapture("add", f.path); + } + + // Commit + const commitResult = await gitCapture("commit", "-m", "Initial dotfiles"); + if (commitResult.exitCode !== 0) { + console.error(`Commit failed: ${commitResult.stderr}`); + return 1; + } + + console.log(`\nAdded ${selected.length} files.`); + } + + // 5. Guide remote setup + const ghAvailable = await isGhReady(); + + if (ghAvailable) { + console.log("\nGitHub CLI detected. Set up a remote?"); + console.log(" [c] Create new private repo on GitHub"); + console.log(" [e] Enter an existing repo URL"); + console.log(" [s] Skip for now\n"); + + const choice = await ask(rl, "> "); + + if (choice.toLowerCase() === "c") { + const repoName = await ask(rl, "Repo name (default: dotfiles): "); + const name = repoName || "dotfiles"; + const createResult = await Bun.$`gh repo create ${name} --private --confirm`.quiet().nothrow(); + + if (createResult.exitCode !== 0) { + console.error(`Failed to create repo: ${createResult.stderr.toString().trim()}`); + console.log('You can create it manually and run: dotfiles remote add origin '); + } else { + // Get the SSH URL of the newly created repo + const urlResult = await Bun.$`gh repo view ${name} --json sshUrl -q .sshUrl`.quiet().nothrow(); + const sshUrl = urlResult.stdout.toString().trim(); + + if (sshUrl) { + const remoteResult = await gitCapture("remote", "add", "origin", sshUrl); + if (remoteResult.exitCode !== 0) { + console.error(`Failed to add remote: ${remoteResult.stderr}`); + return 1; + } + console.log(`\nCreated ${name} on GitHub and added as remote.`); + console.log(`Run "dotfiles push -u origin main" to push.`); + } + } + } else if (choice.toLowerCase() === "e") { + const url = await ask(rl, "Repo URL: "); + if (url) { + const remoteResult = await gitCapture("remote", "add", "origin", url); + if (remoteResult.exitCode !== 0) { + console.error(`Failed to add remote: ${remoteResult.stderr}`); + return 1; + } + console.log(`\nRemote added. Run "dotfiles push -u origin main" to push.`); + } + } + } else { + console.log("\nAdd a remote to sync across machines."); + console.log("Tip: Install GitHub CLI (gh) for guided repo creation.\n"); + + const remoteUrl = await ask(rl, "Enter repo URL (or press Enter to skip):\n> "); + if (remoteUrl) { + const remoteResult = await gitCapture("remote", "add", "origin", remoteUrl); + if (remoteResult.exitCode !== 0) { + console.error(`Failed to add remote: ${remoteResult.stderr}`); + return 1; + } + console.log(`\nRemote added. Run "dotfiles push -u origin main" to push.`); + } + } + } finally { + rl.close(); + } + + return 0; +} diff --git a/src/commands/ls.ts b/src/commands/ls.ts new file mode 100644 index 0000000..a7d1499 --- /dev/null +++ b/src/commands/ls.ts @@ -0,0 +1,9 @@ +import { gitCapture } from "../git.ts"; + +export async function run(_args: string[]): Promise { + const result = await gitCapture("ls-files"); + if (result.stdout) { + console.log(result.stdout); + } + return 0; +} diff --git a/src/commands/merge.ts b/src/commands/merge.ts new file mode 100644 index 0000000..91fd86f --- /dev/null +++ b/src/commands/merge.ts @@ -0,0 +1,265 @@ +import { gitCapture, gitPassthrough, HOME } from "../git.ts"; +import { createInterface } from "node:readline"; +import { mkdirSync, copyFileSync, existsSync, writeFileSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +// ANSI color helpers +const BOLD = "\x1b[1m"; +const RESET = "\x1b[0m"; +const YELLOW = "\x1b[33m"; +const GREEN = "\x1b[32m"; +const RED = "\x1b[31m"; +const DIM = "\x1b[2m"; +const CYAN = "\x1b[36m"; + +interface FileChange { + status: string; + file: string; +} + +function parseNameStatus(output: string): FileChange[] { + if (!output.trim()) return []; + return output + .trim() + .split("\n") + .map((line) => { + const [status, ...rest] = line.trim().split(/\s+/); + return { status: status ?? "", file: rest.join(" ") }; + }) + .filter((entry) => entry.status && entry.file); +} + +function prompt(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase()); + }); + }); +} + +function timestamp(): string { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`; +} + +export async function run(_args: string[]): Promise { + // Step a: Fetch origin + const fetchResult = await gitCapture("fetch", "origin"); + if (fetchResult.exitCode !== 0) { + console.error(`${RED}Error: Could not fetch from origin.${RESET}`); + console.error( + `${DIM}Are you online? Is the remote configured?${RESET}` + ); + return 1; + } + + // Step b: Identify diverged files (same logic as diff) + const localResult = await gitCapture("diff", "--name-status"); + const localChanges = parseNameStatus(localResult.stdout); + + const incomingResult = await gitCapture( + "diff", + "HEAD..origin/main", + "--name-status" + ); + if (incomingResult.exitCode !== 0) { + console.log("Nothing to merge -- no conflicts found."); + return 0; + } + const incomingChanges = parseNameStatus(incomingResult.stdout); + + const localFiles = new Set(localChanges.map((c) => c.file)); + const diverged = incomingChanges.filter((c) => localFiles.has(c.file)); + + // Step c: If none diverged + if (diverged.length === 0) { + console.log("Nothing to merge -- no conflicts found."); + return 0; + } + + // Step d: Create backup directory + const backupDir = `${HOME}/.dotfiles-backup-resolve-${timestamp()}`; + mkdirSync(backupDir, { recursive: true }); + console.log( + `${DIM}Backing up to ${backupDir}/${RESET}\n` + ); + + const resolvedFiles: string[] = []; + + // Step e: Walk through each diverged file + for (let i = 0; i < diverged.length; i++) { + const { file } = diverged[i]; + const fullPath = file.startsWith("/") ? file : join(HOME, file); + + console.log( + `${BOLD}--- ${file} (${i + 1}/${diverged.length}) ---${RESET}\n` + ); + + // Backup the original before any changes + if (existsSync(fullPath)) { + const backupPath = join(backupDir, file); + mkdirSync(dirname(backupPath), { recursive: true }); + copyFileSync(fullPath, backupPath); + } + + // Show local diff + const localDiff = await gitCapture("diff", "HEAD", "--", file); + if (localDiff.stdout) { + console.log(` ${YELLOW}Local change:${RESET}`); + for (const line of localDiff.stdout.split("\n").slice(4)) { + // Skip diff header, show content lines + if (line.startsWith("+")) { + console.log(` ${GREEN}${line}${RESET}`); + } else if (line.startsWith("-")) { + console.log(` ${RED}${line}${RESET}`); + } else if (line.startsWith("@@")) { + console.log(` ${CYAN}${line}${RESET}`); + } else { + console.log(` ${line}`); + } + } + console.log(); + } + + // Show remote diff + const remoteDiff = await gitCapture( + "diff", + "HEAD..origin/main", + "--", + file + ); + if (remoteDiff.stdout) { + console.log(` ${YELLOW}Remote change:${RESET}`); + for (const line of remoteDiff.stdout.split("\n").slice(4)) { + if (line.startsWith("+")) { + console.log(` ${GREEN}${line}${RESET}`); + } else if (line.startsWith("-")) { + console.log(` ${RED}${line}${RESET}`); + } else if (line.startsWith("@@")) { + console.log(` ${CYAN}${line}${RESET}`); + } else { + console.log(` ${line}`); + } + } + console.log(); + } + + // Step e: Show options + console.log( + ` ${DIM}[l] Keep local [r] Keep remote [m] Manual merge [s] Skip [q] Quit${RESET}` + ); + const choice = await prompt(" > "); + + // Step f: Handle actions + switch (choice) { + case "l": { + // Keep local: already in working tree, just stage it + await gitCapture("add", file); + resolvedFiles.push(file); + console.log(` ${GREEN}Staged local version of ${file}${RESET}\n`); + break; + } + case "r": { + // Keep remote: checkout remote version and stage + await gitCapture("checkout", "origin/main", "--", file); + await gitCapture("add", file); + resolvedFiles.push(file); + console.log(` ${GREEN}Staged remote version of ${file}${RESET}\n`); + break; + } + case "m": { + // Manual merge: write conflict markers, open $EDITOR + const localContent = existsSync(fullPath) + ? readFileSync(fullPath, "utf-8") + : ""; + + // Get the remote version content + const remoteContent = await gitCapture( + "show", + `origin/main:${file}` + ); + + const merged = [ + "<<<<<<< LOCAL", + localContent, + "=======", + remoteContent.stdout, + ">>>>>>> REMOTE", + ].join("\n"); + + writeFileSync(fullPath, merged, "utf-8"); + + const editor = process.env.EDITOR || "vi"; + console.log( + ` ${CYAN}Opening ${editor} for manual merge...${RESET}` + ); + + // Open editor (passthrough so user can interact) + const editorProc = Bun.spawn([editor, fullPath], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + await editorProc.exited; + + // After editor closes, stage the file + await gitCapture("add", file); + resolvedFiles.push(file); + console.log( + ` ${GREEN}Staged manually merged ${file}${RESET}\n` + ); + break; + } + case "s": { + // Skip: restore from backup and move on + console.log(` ${DIM}Skipped ${file}${RESET}\n`); + break; + } + case "q": { + // Quit: stop processing remaining files + console.log(`\n${DIM}Quit -- remaining files left unresolved.${RESET}`); + // Still offer to commit what was resolved so far + break; + } + default: { + console.log(` ${DIM}Unknown choice, skipping ${file}${RESET}\n`); + break; + } + } + + if (choice === "q") break; + } + + // Step g: After all files processed, prompt to commit + if (resolvedFiles.length === 0) { + console.log(`\n${DIM}No files were resolved.${RESET}`); + return 0; + } + + console.log( + `\n${GREEN}Resolved ${resolvedFiles.length} file(s).${RESET}` + ); + const commitAnswer = await prompt("Commit resolved files? [y/N] "); + + // Step h: If yes, commit + if (commitAnswer === "y") { + const fileList = resolvedFiles.join(", "); + const message = `merge: ${fileList}`; + const commitResult = await gitCapture("commit", "-m", message); + if (commitResult.exitCode === 0) { + console.log(`${GREEN}Committed: ${message}${RESET}`); + } else { + console.error(`${RED}Commit failed:${RESET} ${commitResult.stderr}`); + return 1; + } + } else { + console.log( + `${DIM}Files staged but not committed. Run "dotfiles commit" when ready.${RESET}` + ); + } + + return 0; +} diff --git a/src/commands/sync.ts b/src/commands/sync.ts new file mode 100644 index 0000000..2f14c7a --- /dev/null +++ b/src/commands/sync.ts @@ -0,0 +1,55 @@ +import { gitCapture } from "../git.ts"; + +export async function run(args: string[]): Promise { + // 1. Pull with rebase (suppress errors if no remote) + const pullResult = await gitCapture("pull", "--rebase"); + if (pullResult.exitCode !== 0 && !pullResult.stderr.includes("No remote") && !pullResult.stderr.includes("no tracking information") && !pullResult.stderr.includes("There is no tracking information")) { + // Only warn, don't fail -- remote may not be configured yet + if (pullResult.stderr) { + console.log(`Pull: ${pullResult.stderr}`); + } + } + + // 2. Stage all tracked file changes + const addResult = await gitCapture("add", "-u"); + if (addResult.exitCode !== 0) { + console.error(`Failed to stage changes: ${addResult.stderr}`); + return 1; + } + + // 3. Check if there are changes to commit + const diffResult = await gitCapture("diff", "--cached", "--quiet"); + if (diffResult.exitCode === 0) { + console.log("Nothing to sync -- everything up to date."); + return 0; + } + + // 4. Build commit message + const message = args.length > 0 + ? args.join(" ") + : `sync ${new Date().toISOString().slice(0, 10)}`; + + // 5. Commit + const commitResult = await gitCapture("commit", "-m", message); + if (commitResult.exitCode !== 0) { + console.error(`Commit failed: ${commitResult.stderr}`); + return 1; + } + + // 6. Push (suppress errors if no remote) + const pushResult = await gitCapture("push"); + if (pushResult.exitCode !== 0) { + if (pushResult.stderr && !pushResult.stderr.includes("No configured push destination") && !pushResult.stderr.includes("No remote") && !pushResult.stderr.includes("does not appear to be a git repository")) { + console.log(`Push: ${pushResult.stderr}`); + } + } + + // 7. Print summary + const statusResult = await gitCapture("diff", "--stat", "HEAD~1", "HEAD"); + console.log(`Synced: ${message}`); + if (statusResult.stdout) { + console.log(statusResult.stdout); + } + + return 0; +} diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..378eff7 --- /dev/null +++ b/src/git.ts @@ -0,0 +1,40 @@ +import { $ } from "bun"; + +const HOME = process.env.HOME ?? Bun.env.HOME ?? "/tmp"; +const GIT_DIR = `${HOME}/.dotfiles`; +const WORK_TREE = HOME; + +/** Base git args for bare repo */ +const baseArgs = ["--git-dir", GIT_DIR, "--work-tree", WORK_TREE]; + +/** Run a git command quietly and return stdout as string */ +export async function gitCapture( + ...args: string[] +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const result = await $`git ${baseArgs} ${args}`.quiet().nothrow(); + return { + stdout: result.stdout.toString().trim(), + stderr: result.stderr.toString().trim(), + exitCode: result.exitCode, + }; +} + +/** Run a git command with passthrough (shows output to user) */ +export async function gitPassthrough(...args: string[]): Promise { + const result = await $`git ${baseArgs} ${args}`.nothrow(); + return result.exitCode; +} + +/** Run raw git (no bare repo flags) — for bootstrap clone */ +export async function gitRaw( + ...args: string[] +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const result = await $`git ${args}`.quiet().nothrow(); + return { + stdout: result.stdout.toString().trim(), + stderr: result.stderr.toString().trim(), + exitCode: result.exitCode, + }; +} + +export { HOME, GIT_DIR, WORK_TREE, baseArgs }; diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..4f7292a --- /dev/null +++ b/tests/README.md @@ -0,0 +1,32 @@ +# Tests + +## What's tested + +**Contract tests** — verify modules are wired up correctly: +- Every command module exports a `run(args): Promise` function +- `cli.ts` has all 9 command entries and a git pass-through fallback +- `git.ts` exports the expected functions and path constants + +**Pattern unit tests** — verify detection logic in isolation: +- Audit filename patterns (`.pem`, `.key`, `id_rsa`, `.env`, etc.) +- Audit content regexes (`api_key=`, `secret:`, `token=`, `password=`) +- Negative cases (normal config files don't trigger false positives) + +**Functional tests** — actually execute and check output: +- `help` command returns 0 and prints all 9 command names +- `gitRaw` executes real git commands and returns structured results + +## What's not tested + +Commands that need a bare repo + interactive stdin (`init`, `sync`, `bootstrap`, `diff`, `merge`, `audit`) are not integration tested. They would need: +- Temp directories with bare git repos +- Mocked stdin for interactive prompts +- Real file conflict scenarios for bootstrap/merge + +These are tested manually per the PR test plan. + +## Running + +```bash +bun test +``` diff --git a/tests/audit.test.ts b/tests/audit.test.ts new file mode 100644 index 0000000..a250c6a --- /dev/null +++ b/tests/audit.test.ts @@ -0,0 +1,122 @@ +import { test, expect, describe } from "bun:test"; + +// We test the pattern-matching logic directly since the run() function +// requires a real bare git repo. We extract the same patterns used in audit.ts. + +const SUSPICIOUS_NAMES = [ + /\.pem$/, + /\.key$/, + /id_rsa$/, + /id_ed25519$/, + /^\.env$/, + /^\.env\..+/, +]; + +const SUSPICIOUS_CONTENT = [ + /api[_-]?key\s*[=:]/i, + /secret\s*[=:]/i, + /token\s*[=:]/i, + /password\s*[=:]/i, + /private[_-]?key/i, +]; + +function checkFilename(basename: string): boolean { + return SUSPICIOUS_NAMES.some((pattern) => pattern.test(basename)); +} + +function checkContent(content: string): boolean { + return SUSPICIOUS_CONTENT.some((pattern) => pattern.test(content)); +} + +describe("audit filename pattern matching", () => { + test("detects .pem files", () => { + expect(checkFilename("server.pem")).toBe(true); + expect(checkFilename("cert.pem")).toBe(true); + }); + + test("detects .key files", () => { + expect(checkFilename("private.key")).toBe(true); + expect(checkFilename("ssl.key")).toBe(true); + }); + + test("detects id_rsa", () => { + expect(checkFilename("id_rsa")).toBe(true); + }); + + test("detects id_ed25519", () => { + expect(checkFilename("id_ed25519")).toBe(true); + }); + + test("detects .env", () => { + expect(checkFilename(".env")).toBe(true); + }); + + test("detects .env.local and .env.production", () => { + expect(checkFilename(".env.local")).toBe(true); + expect(checkFilename(".env.production")).toBe(true); + expect(checkFilename(".env.staging")).toBe(true); + }); + + test("does not flag normal config files", () => { + expect(checkFilename(".zshrc")).toBe(false); + expect(checkFilename(".gitconfig")).toBe(false); + expect(checkFilename("config")).toBe(false); + expect(checkFilename("starship.toml")).toBe(false); + expect(checkFilename(".vimrc")).toBe(false); + }); + + test("does not flag .env without dot prefix (bare 'env')", () => { + expect(checkFilename("env")).toBe(false); + }); + + test("does not flag files containing 'key' in the middle of the name", () => { + expect(checkFilename("keyboard.txt")).toBe(false); + expect(checkFilename("monkey.conf")).toBe(false); + }); +}); + +describe("audit content pattern matching", () => { + test("detects api_key assignments", () => { + expect(checkContent("api_key = abc123")).toBe(true); + expect(checkContent("API_KEY=secret")).toBe(true); + expect(checkContent("api-key: something")).toBe(true); + expect(checkContent("apikey = foo")).toBe(true); + }); + + test("detects secret assignments", () => { + expect(checkContent("secret = mysecret")).toBe(true); + expect(checkContent("SECRET: value")).toBe(true); + expect(checkContent('client_secret = "abc"')).toBe(true); + }); + + test("detects token assignments", () => { + expect(checkContent("token = abc")).toBe(true); + expect(checkContent("TOKEN: xyz")).toBe(true); + expect(checkContent("access_token = foo")).toBe(true); + }); + + test("detects password assignments", () => { + expect(checkContent("password = pass123")).toBe(true); + expect(checkContent("PASSWORD: hunter2")).toBe(true); + expect(checkContent('db_password = "secret"')).toBe(true); + }); + + test("detects private_key references", () => { + expect(checkContent("private_key")).toBe(true); + expect(checkContent("PRIVATE-KEY")).toBe(true); + expect(checkContent("privatekey")).toBe(true); + }); + + test("does not flag normal configuration content", () => { + expect(checkContent("[user]\n name = John")).toBe(false); + expect(checkContent("color.ui = auto")).toBe(false); + expect(checkContent("export PATH=$HOME/bin:$PATH")).toBe(false); + expect(checkContent("alias ls='ls -la'")).toBe(false); + }); + + test("does not flag comments about secrets in general", () => { + // These do NOT match because there's no = or : after the keyword + expect(checkContent("# remember to rotate secrets")).toBe(false); + expect(checkContent("# tokens expire after 30 days")).toBe(false); + }); +}); diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..91672fb --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,137 @@ +import { test, expect, describe } from "bun:test"; + +// We cannot import cli.ts directly because it calls main() on import +// (it has a top-level main().catch() invocation that calls process.exit). +// Instead we test the COMMANDS map structure by reading the file +// and verifying the expected commands are present. + +const EXPECTED_COMMANDS = [ + "help", + "init", + "sync", + "ls", + "audit", + "bootstrap", + "add", + "diff", + "merge", +] as const; + +describe("CLI commands map", () => { + test("cli.ts file exists and is readable", async () => { + const file = Bun.file("/Users/ianmarr/projects/dotfiles-cli/src/cli.ts"); + const exists = await file.exists(); + expect(exists).toBe(true); + }); + + test("cli.ts contains all 9 expected command keys", async () => { + const content = await Bun.file("/Users/ianmarr/projects/dotfiles-cli/src/cli.ts").text(); + + for (const cmd of EXPECTED_COMMANDS) { + // Each command should appear as a key in the COMMANDS record + expect(content).toContain(`${cmd}:`); + } + }); + + test("cli.ts has exactly 9 commands (no extras)", async () => { + const content = await Bun.file("/Users/ianmarr/projects/dotfiles-cli/src/cli.ts").text(); + + // Extract the COMMANDS block between the opening { and closing }; + const commandsMatch = content.match(/COMMANDS[^{]*\{([\s\S]*?)\};/); + expect(commandsMatch).not.toBeNull(); + + const commandsBlock = commandsMatch![1]; + // Count lines that look like command entries: word followed by colon and arrow function + const entries = commandsBlock.match(/^\s+\w+:\s*\(\)/gm); + expect(entries).not.toBeNull(); + expect(entries!.length).toBe(9); + }); + + test("each command imports from ./commands/ directory", async () => { + const content = await Bun.file("/Users/ianmarr/projects/dotfiles-cli/src/cli.ts").text(); + + for (const cmd of EXPECTED_COMMANDS) { + expect(content).toContain(`./commands/${cmd}.ts`); + } + }); + + test("cli.ts handles --help and -h flags", async () => { + const content = await Bun.file("/Users/ianmarr/projects/dotfiles-cli/src/cli.ts").text(); + expect(content).toContain("--help"); + expect(content).toContain("-h"); + }); + + test("cli.ts handles empty command (no args)", async () => { + const content = await Bun.file("/Users/ianmarr/projects/dotfiles-cli/src/cli.ts").text(); + // !command checks for undefined/empty + expect(content).toContain("!command"); + }); + + test("cli.ts has git pass-through for unknown commands", async () => { + const content = await Bun.file("/Users/ianmarr/projects/dotfiles-cli/src/cli.ts").text(); + expect(content).toContain("gitPassthrough"); + // The pass-through happens after the command-in-COMMANDS check + expect(content).toContain("command in COMMANDS"); + }); + + test("cli.ts slices process.argv correctly", async () => { + const content = await Bun.file("/Users/ianmarr/projects/dotfiles-cli/src/cli.ts").text(); + // argv[0] = bun, argv[1] = script, argv[2+] = user args + expect(content).toContain("process.argv.slice(2)"); + }); + + test("diff command with extra args passes through to git", async () => { + const content = await Bun.file("/Users/ianmarr/projects/dotfiles-cli/src/cli.ts").text(); + // Special case: diff with args goes to gitPassthrough + expect(content).toContain('command === "diff"'); + expect(content).toContain("restArgs.length > 0"); + }); +}); + +describe("command module contracts", () => { + // Verify each command module exports a run function + test("help module exports run function", async () => { + const mod = await import("../src/commands/help.ts"); + expect(typeof mod.run).toBe("function"); + }); + + test("ls module exports run function", async () => { + const mod = await import("../src/commands/ls.ts"); + expect(typeof mod.run).toBe("function"); + }); + + test("audit module exports run function", async () => { + const mod = await import("../src/commands/audit.ts"); + expect(typeof mod.run).toBe("function"); + }); + + test("sync module exports run function", async () => { + const mod = await import("../src/commands/sync.ts"); + expect(typeof mod.run).toBe("function"); + }); + + test("add module exports run function", async () => { + const mod = await import("../src/commands/add.ts"); + expect(typeof mod.run).toBe("function"); + }); + + test("diff module exports run function", async () => { + const mod = await import("../src/commands/diff.ts"); + expect(typeof mod.run).toBe("function"); + }); + + test("merge module exports run function", async () => { + const mod = await import("../src/commands/merge.ts"); + expect(typeof mod.run).toBe("function"); + }); + + test("init module exports run function", async () => { + const mod = await import("../src/commands/init.ts"); + expect(typeof mod.run).toBe("function"); + }); + + test("bootstrap module exports run function", async () => { + const mod = await import("../src/commands/bootstrap.ts"); + expect(typeof mod.run).toBe("function"); + }); +}); diff --git a/tests/git.test.ts b/tests/git.test.ts new file mode 100644 index 0000000..777a6b0 --- /dev/null +++ b/tests/git.test.ts @@ -0,0 +1,63 @@ +import { test, expect, describe } from "bun:test"; +import { gitCapture, gitPassthrough, gitRaw, HOME, GIT_DIR, WORK_TREE, baseArgs } from "../src/git.ts"; + +describe("git module exports", () => { + test("gitCapture is an async function", () => { + expect(typeof gitCapture).toBe("function"); + }); + + test("gitPassthrough is an async function", () => { + expect(typeof gitPassthrough).toBe("function"); + }); + + test("gitRaw is an async function", () => { + expect(typeof gitRaw).toBe("function"); + }); +}); + +describe("git path constants", () => { + test("HOME is a non-empty string", () => { + expect(typeof HOME).toBe("string"); + expect(HOME.length).toBeGreaterThan(0); + }); + + test("HOME matches process.env.HOME", () => { + const expectedHome = process.env.HOME ?? Bun.env.HOME ?? "/tmp"; + expect(HOME).toBe(expectedHome); + }); + + test("GIT_DIR is HOME/.dotfiles", () => { + expect(GIT_DIR).toBe(`${HOME}/.dotfiles`); + }); + + test("WORK_TREE equals HOME", () => { + expect(WORK_TREE).toBe(HOME); + }); + + test("baseArgs contains git-dir and work-tree flags", () => { + expect(baseArgs).toEqual(["--git-dir", GIT_DIR, "--work-tree", WORK_TREE]); + }); +}); + +describe("gitRaw runs git without bare repo flags", () => { + test("gitRaw can run git --version", async () => { + const result = await gitRaw("--version"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("git version"); + }); + + test("gitRaw returns structured result with stdout, stderr, exitCode", async () => { + const result = await gitRaw("--version"); + expect(result).toHaveProperty("stdout"); + expect(result).toHaveProperty("stderr"); + expect(result).toHaveProperty("exitCode"); + expect(typeof result.stdout).toBe("string"); + expect(typeof result.stderr).toBe("string"); + expect(typeof result.exitCode).toBe("number"); + }); + + test("gitRaw returns non-zero for invalid git command", async () => { + const result = await gitRaw("not-a-real-command"); + expect(result.exitCode).not.toBe(0); + }); +}); diff --git a/tests/help.test.ts b/tests/help.test.ts new file mode 100644 index 0000000..78f6ed4 --- /dev/null +++ b/tests/help.test.ts @@ -0,0 +1,76 @@ +import { test, expect, describe, spyOn, afterEach } from "bun:test"; +import { run } from "../src/commands/help.ts"; + +describe("help command", () => { + let logSpy: ReturnType; + let logged: string[] = []; + + afterEach(() => { + if (logSpy) logSpy.mockRestore(); + logged = []; + }); + + function captureLog() { + logged = []; + logSpy = spyOn(console, "log").mockImplementation((...args: unknown[]) => { + logged.push(args.map(String).join(" ")); + }); + } + + test("run returns exit code 0", async () => { + captureLog(); + const code = await run([]); + expect(code).toBe(0); + }); + + test("run returns 0 when called with arbitrary args", async () => { + captureLog(); + const code = await run(["--verbose", "extra"]); + expect(code).toBe(0); + }); + + test("output contains all custom command names", async () => { + captureLog(); + await run([]); + const output = logged.join("\n"); + + const expectedCommands = [ + "help", + "init", + "sync", + "ls", + "audit", + "bootstrap", + "add", + "diff", + "merge", + ]; + + for (const cmd of expectedCommands) { + expect(output).toContain(cmd); + } + }); + + test("output contains the tool name 'dotfiles'", async () => { + captureLog(); + await run([]); + const output = logged.join("\n"); + expect(output).toContain("dotfiles"); + }); + + test("output mentions git pass-through", async () => { + captureLog(); + await run([]); + const output = logged.join("\n"); + expect(output.toLowerCase()).toContain("pass-through"); + }); + + test("output mentions common git passthrough examples", async () => { + captureLog(); + await run([]); + const output = logged.join("\n"); + expect(output).toContain("status"); + expect(output).toContain("log"); + expect(output).toContain("stash"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fa7bf44 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["bun-types"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["src/**/*"] +}