git-drive 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.github/workflows/ci.yml +77 -0
  2. package/.planning/codebase/ARCHITECTURE.md +151 -0
  3. package/.planning/codebase/CONCERNS.md +191 -0
  4. package/.planning/codebase/CONVENTIONS.md +169 -0
  5. package/.planning/codebase/INTEGRATIONS.md +94 -0
  6. package/.planning/codebase/STACK.md +77 -0
  7. package/.planning/codebase/STRUCTURE.md +157 -0
  8. package/.planning/codebase/TESTING.md +156 -0
  9. package/Dockerfile.cli +30 -0
  10. package/Dockerfile.server +32 -0
  11. package/README.md +95 -0
  12. package/docker-compose.yml +48 -0
  13. package/package.json +25 -0
  14. package/packages/cli/Dockerfile +26 -0
  15. package/packages/cli/package.json +57 -0
  16. package/packages/cli/src/commands/archive.ts +39 -0
  17. package/packages/cli/src/commands/init.ts +34 -0
  18. package/packages/cli/src/commands/link.ts +115 -0
  19. package/packages/cli/src/commands/list.ts +94 -0
  20. package/packages/cli/src/commands/push.ts +64 -0
  21. package/packages/cli/src/commands/restore.ts +36 -0
  22. package/packages/cli/src/commands/status.ts +127 -0
  23. package/packages/cli/src/config.ts +73 -0
  24. package/packages/cli/src/errors.ts +23 -0
  25. package/packages/cli/src/git.ts +55 -0
  26. package/packages/cli/src/index.ts +97 -0
  27. package/packages/cli/src/server.ts +514 -0
  28. package/packages/cli/tsconfig.json +13 -0
  29. package/packages/cli/ui/assets/index-Cc2q1t5k.js +17 -0
  30. package/packages/cli/ui/assets/index-DrL7ojPA.css +1 -0
  31. package/packages/cli/ui/index.html +14 -0
  32. package/packages/cli/ui/vite.svg +1 -0
  33. package/packages/git-drive-docker/package.json +15 -0
  34. package/packages/server/package.json +44 -0
  35. package/packages/server/src/index.ts +569 -0
  36. package/packages/server/tsconfig.json +9 -0
  37. package/packages/ui/README.md +73 -0
  38. package/packages/ui/eslint.config.js +23 -0
  39. package/packages/ui/index.html +13 -0
  40. package/packages/ui/package.json +42 -0
  41. package/packages/ui/postcss.config.js +6 -0
  42. package/packages/ui/public/vite.svg +1 -0
  43. package/packages/ui/src/App.css +23 -0
  44. package/packages/ui/src/App.tsx +726 -0
  45. package/packages/ui/src/assets/react.svg +8 -0
  46. package/packages/ui/src/assets/vite.svg +3 -0
  47. package/packages/ui/src/index.css +37 -0
  48. package/packages/ui/src/main.tsx +14 -0
  49. package/packages/ui/tailwind.config.js +11 -0
  50. package/packages/ui/tsconfig.app.json +28 -0
  51. package/packages/ui/tsconfig.json +26 -0
  52. package/packages/ui/tsconfig.node.json +12 -0
  53. package/packages/ui/vite.config.ts +7 -0
  54. package/pnpm-workspace.yaml +4 -0
  55. package/rewrite_app.js +731 -0
  56. package/tsconfig.json +14 -0
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "git-drive",
3
+ "version": "0.1.0",
4
+ "description": "Turn any external drive into a git remote backup for your code - CLI, server, and web UI",
5
+ "keywords": [
6
+ "git",
7
+ "backup",
8
+ "external-drive",
9
+ "usb",
10
+ "remote",
11
+ "cli",
12
+ "docker"
13
+ ],
14
+ "author": "",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/josmanvis/git-drive.git",
19
+ "directory": "packages/cli"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/josmanvis/git-drive/issues"
23
+ },
24
+ "homepage": "https://github.com/josmanvis/git-drive#readme",
25
+ "type": "commonjs",
26
+ "bin": {
27
+ "git-drive": "./dist/index.js",
28
+ "git-drive-server": "./dist/server.js"
29
+ },
30
+ "main": "./dist/index.js",
31
+ "files": [
32
+ "dist",
33
+ "ui"
34
+ ],
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.json",
37
+ "start": "node dist/index.js",
38
+ "start:server": "node dist/server.js",
39
+ "docker:build": "docker build -t git-drive .",
40
+ "docker:run": "docker run -it --rm -v /Volumes:/Volumes -p 4483:4483 git-drive",
41
+ "prepublishOnly": "npm run build"
42
+ },
43
+ "dependencies": {
44
+ "express": "^4.19.2",
45
+ "node-disk-info": "^1.3.0",
46
+ "prompts": "^2.4.2"
47
+ },
48
+ "devDependencies": {
49
+ "@types/express": "^4.17.21",
50
+ "@types/node": "^22.0.0",
51
+ "@types/prompts": "^2.4.9",
52
+ "typescript": "^5.7.0"
53
+ },
54
+ "engines": {
55
+ "node": ">=18"
56
+ }
57
+ }
@@ -0,0 +1,39 @@
1
+ import { rmSync } from "fs";
2
+ import { requireConfig, assertDriveMounted } from "../config.js";
3
+ import { git, getRepoRoot, getProjectName, isGitRepo } from "../git.js";
4
+ import { push } from "./push.js";
5
+ import { GitDriveError } from "../errors.js";
6
+
7
+ export function archive(args: string[]): void {
8
+ if (!isGitRepo()) {
9
+ throw new GitDriveError("Not in a git repository.");
10
+ }
11
+
12
+ const force = args.includes("--force");
13
+
14
+ // Check for uncommitted changes
15
+ if (!force) {
16
+ const status = git("status --porcelain");
17
+ if (status) {
18
+ throw new GitDriveError(
19
+ "Working tree has uncommitted changes.\nCommit first or use --force to archive anyway."
20
+ );
21
+ }
22
+ }
23
+
24
+ const config = requireConfig();
25
+ assertDriveMounted(config.drivePath);
26
+
27
+ const projectName = getProjectName();
28
+ const repoRoot = getRepoRoot();
29
+
30
+ // Push first
31
+ push([]);
32
+
33
+ // Remove local copy
34
+ process.chdir("..");
35
+ rmSync(repoRoot, { recursive: true, force: true });
36
+
37
+ console.log(`Archived: ${projectName}`);
38
+ console.log(`Restore with: git drive restore ${projectName}`);
39
+ }
@@ -0,0 +1,34 @@
1
+ import { existsSync, statSync, mkdirSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { saveConfig, getDriveStorePath } from "../config.js";
4
+ import { GitDriveError } from "../errors.js";
5
+
6
+ export function init(args: string[]): void {
7
+ const rawPath = args[0];
8
+
9
+ if (!rawPath) {
10
+ throw new GitDriveError("Usage: git drive init <path>");
11
+ }
12
+
13
+ const drivePath = resolve(rawPath);
14
+
15
+ if (!existsSync(drivePath)) {
16
+ throw new GitDriveError(
17
+ `Path not found: ${drivePath}\nIs the drive mounted?`
18
+ );
19
+ }
20
+
21
+ const stat = statSync(drivePath);
22
+ if (!stat.isDirectory()) {
23
+ throw new GitDriveError(`Path is not a directory: ${drivePath}`);
24
+ }
25
+
26
+ const storePath = getDriveStorePath(drivePath);
27
+ if (!existsSync(storePath)) {
28
+ mkdirSync(storePath, { recursive: true });
29
+ }
30
+
31
+ saveConfig({ drivePath });
32
+
33
+ console.log(`Drive configured: ${storePath}`);
34
+ }
@@ -0,0 +1,115 @@
1
+ import { handleError } from "../errors.js";
2
+ import { saveLink } from "../config.js";
3
+ import { git, getProjectName, getRepoRoot, listDrives } from "../git.js";
4
+ import prompts from "prompts";
5
+ import * as fs from "fs";
6
+ import * as path from "path";
7
+
8
+ export async function link(args: string[]): Promise<void> {
9
+ try {
10
+ const drives = await listDrives();
11
+
12
+ // Only fetch drives that actually have .git-drive configured
13
+ const configuredDrives = drives
14
+ .filter((drive) => drive.mounted && drive.mounted !== "/")
15
+ .filter((drive) => fs.existsSync(path.join(drive.mounted, ".git-drive")));
16
+
17
+ if (configuredDrives.length === 0) {
18
+ console.log("No initialized git-drives found. Please initialize a drive first.");
19
+ return;
20
+ }
21
+
22
+ const { drive } = await prompts({
23
+ type: "select",
24
+ name: "drive",
25
+ message: "Select a configured git-drive:",
26
+ choices: configuredDrives.map((d) => ({
27
+ title: `${d.filesystem} (${d.mounted})`,
28
+ value: d,
29
+ })),
30
+ });
31
+
32
+ if (!drive) return;
33
+
34
+ const gitDrivePath = path.join(drive.mounted, ".git-drive");
35
+ const existingRepos = fs.readdirSync(gitDrivePath).filter((entry) => {
36
+ const entryPath = path.join(gitDrivePath, entry);
37
+ return (
38
+ fs.statSync(entryPath).isDirectory() &&
39
+ (entry.endsWith(".git") || fs.existsSync(path.join(entryPath, "HEAD")))
40
+ );
41
+ });
42
+
43
+ const CREATE_NEW = "__CREATE_NEW__";
44
+ const { selectedRepo } = await prompts({
45
+ type: "select",
46
+ name: "selectedRepo",
47
+ message: "Select an existing repository to link, or create a new one:",
48
+ choices: [
49
+ { title: "✨ Create new repository...", value: CREATE_NEW },
50
+ ...existingRepos.map((repo) => ({
51
+ title: `📁 ${repo.replace(/\.git$/, "")}`,
52
+ value: repo,
53
+ })),
54
+ ],
55
+ });
56
+
57
+ if (!selectedRepo) return;
58
+
59
+ let targetRepoName = selectedRepo;
60
+
61
+ if (selectedRepo === CREATE_NEW) {
62
+ const defaultName = getProjectName();
63
+ const { newRepoName } = await prompts({
64
+ type: "text",
65
+ name: "newRepoName",
66
+ message: "Enter the new repository name:",
67
+ initial: defaultName,
68
+ });
69
+
70
+ if (!newRepoName) return;
71
+ targetRepoName = newRepoName.endsWith(".git") ? newRepoName : `${newRepoName}.git`;
72
+
73
+ const repoPath = path.join(gitDrivePath, targetRepoName);
74
+ if (fs.existsSync(repoPath)) {
75
+ console.log(`Repository ${targetRepoName} already exists in this drive.`);
76
+ return;
77
+ }
78
+
79
+ git(`init --bare "${repoPath}"`);
80
+ console.log(`Created new bare repository: ${targetRepoName}`);
81
+ }
82
+
83
+ const repoRoot = getRepoRoot();
84
+ const finalRepoPath = path.join(gitDrivePath, targetRepoName);
85
+
86
+ // Check if remote 'gd' already exists
87
+ let gdExists = false;
88
+ try {
89
+ git(`remote get-url gd`, repoRoot);
90
+ gdExists = true;
91
+ } catch {
92
+ // Remote does not exist
93
+ }
94
+
95
+ if (gdExists) {
96
+ console.log("Remote 'gd' already exists. Updating it to point to the new drive.");
97
+ git(`remote set-url gd "${finalRepoPath}"`, repoRoot);
98
+ } else {
99
+ git(`remote add gd "${finalRepoPath}"`, repoRoot);
100
+ }
101
+
102
+ // Persist to global git-drive registry for the Web UI
103
+ saveLink(repoRoot, drive.mounted, targetRepoName);
104
+
105
+ console.log(`\n✅ Successfully linked!`);
106
+ console.log(`Repository: ${targetRepoName.replace(/\.git$/, "")}`);
107
+ console.log(`Drive: ${drive.mounted}`);
108
+ console.log(`\nYou can now push to this remote using:`);
109
+ console.log(` git push gd main`);
110
+
111
+ } catch (err) {
112
+ handleError(err);
113
+ process.exit(1);
114
+ }
115
+ }
@@ -0,0 +1,94 @@
1
+ import { readdirSync, statSync, existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ import { getDiskInfo } from "node-disk-info";
5
+
6
+ interface LinkConfig {
7
+ mountpoint: string;
8
+ repoName: string;
9
+ linkedAt: string;
10
+ }
11
+
12
+ function loadLinks(): Record<string, LinkConfig> {
13
+ const linksFile = join(homedir(), ".config", "git-drive", "links.json");
14
+ if (!existsSync(linksFile)) return {};
15
+ try {
16
+ return JSON.parse(readFileSync(linksFile, "utf-8"));
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ function getGitDrivePath(mountpoint: string): string {
23
+ return join(mountpoint, ".git-drive");
24
+ }
25
+
26
+ export async function list(_args: string[]): Promise<void> {
27
+ console.log("Git Drive - Connected Drives\n");
28
+
29
+ // Get all connected drives
30
+ let drives: any[] = [];
31
+ try {
32
+ drives = await getDiskInfo();
33
+ } catch (err) {
34
+ console.error("Error detecting drives:", err);
35
+ return;
36
+ }
37
+
38
+ // Filter to external/removable drives
39
+ const externalDrives = drives.filter((d: any) => {
40
+ const mp = d.mounted;
41
+ if (!mp) return false;
42
+ if (mp === "/" || mp === "100%") return false;
43
+
44
+ if (process.platform === "darwin") {
45
+ return mp.startsWith("/Volumes/") && !mp.startsWith("/Volumes/Recovery");
46
+ }
47
+
48
+ if (mp.startsWith("/sys") || mp.startsWith("/proc") || mp.startsWith("/run") || mp.startsWith("/snap") || mp.startsWith("/boot")) return false;
49
+ if (d.filesystem === "tmpfs" || d.filesystem === "devtmpfs" || d.filesystem === "udev" || d.filesystem === "overlay") return false;
50
+
51
+ return true;
52
+ });
53
+
54
+ if (externalDrives.length === 0) {
55
+ console.log("No external drives detected.");
56
+ console.log("\nConnect an external drive and try again.");
57
+ return;
58
+ }
59
+
60
+ // Load links to show which drives are registered
61
+ const links = loadLinks();
62
+ const registeredMountpoints = new Set(Object.values(links).map(l => l.mountpoint));
63
+
64
+ for (const drive of externalDrives) {
65
+ const mp = drive.mounted;
66
+ const gitDrivePath = getGitDrivePath(mp);
67
+ const hasGitDrive = existsSync(gitDrivePath);
68
+ const isRegistered = registeredMountpoints.has(mp);
69
+
70
+ // Count repos on this drive
71
+ let repoCount = 0;
72
+ if (hasGitDrive) {
73
+ try {
74
+ const entries = readdirSync(gitDrivePath).filter(n => n.endsWith(".git") || existsSync(join(gitDrivePath, n, "HEAD")));
75
+ repoCount = entries.length;
76
+ } catch {}
77
+ }
78
+
79
+ // Format size
80
+ const sizeGB = drive.blocks ? ((parseInt(drive.blocks) * 1024) / (1024 * 1024 * 1024)).toFixed(1) : "?";
81
+
82
+ // Status indicator
83
+ const status = hasGitDrive ? "✓ registered" : "○ not registered";
84
+
85
+ console.log(` ${mp}`);
86
+ console.log(` Size: ${sizeGB} GB`);
87
+ console.log(` Status: ${status}`);
88
+ console.log(` Repositories: ${repoCount}`);
89
+ console.log();
90
+ }
91
+
92
+ console.log(`\n${externalDrives.length} drive${externalDrives.length === 1 ? "" : "s"} detected.`);
93
+ console.log("\nRun 'git-drive link' to link a repo to a drive.");
94
+ }
@@ -0,0 +1,64 @@
1
+ import { git, getRemoteUrl, isGitRepo } from "../git.js";
2
+ import { GitDriveError } from "../errors.js";
3
+ import prompts from "prompts";
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import * as os from "os";
7
+
8
+ export async function push(_args: string[]): Promise<void> {
9
+ if (!isGitRepo()) {
10
+ throw new GitDriveError("Not in a git repository.");
11
+ }
12
+
13
+ const existingUrl = getRemoteUrl("gd");
14
+ if (!existingUrl) {
15
+ throw new GitDriveError("No git-drive linked for this project. Please run 'git-drive link' first.");
16
+ }
17
+
18
+ try {
19
+ const currentBranch = git("branch --show-current") || "HEAD";
20
+
21
+ const { pushMode } = await prompts({
22
+ type: "select",
23
+ name: "pushMode",
24
+ message: `Pushing to ${existingUrl}\nSelect what to branch to push:`,
25
+ choices: [
26
+ { title: `Current branch only (${currentBranch})`, value: "current" },
27
+ { title: "All branches & tags", value: "all" }
28
+ ]
29
+ });
30
+
31
+ if (!pushMode) return;
32
+
33
+ if (pushMode === "current") {
34
+ console.log(`\nPushing ${currentBranch}...`);
35
+ git(`push gd ${currentBranch}`);
36
+ console.log(`✅ Successfully pushed ${currentBranch} to git-drive.`);
37
+ } else {
38
+ console.log("\nPushing all branches and tags...");
39
+ git("push gd --all");
40
+ git("push gd --tags");
41
+ console.log(`✅ Successfully pushed all branches and tags to git-drive.`);
42
+ }
43
+
44
+ // Write context to a central pushlog safely inside the git-drive repo folder
45
+ try {
46
+ if (fs.existsSync(existingUrl)) {
47
+ const payload = {
48
+ date: new Date().toISOString(),
49
+ computer: os.hostname(),
50
+ user: os.userInfo().username,
51
+ localDir: process.cwd(),
52
+ mode: pushMode,
53
+ };
54
+ const logFile = path.join(existingUrl, "git-drive-pushlog.json");
55
+ fs.appendFileSync(logFile, JSON.stringify(payload) + "\n", "utf-8");
56
+ }
57
+ } catch {
58
+ // Intentionally swallow telemetry tracking errors
59
+ }
60
+
61
+ } catch (err: any) {
62
+ throw new GitDriveError(`Failed to push to drive. Make sure the drive is connected.\n${err.message}`);
63
+ }
64
+ }
@@ -0,0 +1,36 @@
1
+ import { existsSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { requireConfig, assertDriveMounted, getDriveStorePath } from "../config.js";
4
+ import { git } from "../git.js";
5
+ import { GitDriveError } from "../errors.js";
6
+
7
+ export function restore(args: string[]): void {
8
+ const projectName = args[0];
9
+ const targetDir = args[1] || projectName;
10
+
11
+ if (!projectName) {
12
+ throw new GitDriveError("Usage: git drive restore <project-name> [target-dir]");
13
+ }
14
+
15
+ const config = requireConfig();
16
+ assertDriveMounted(config.drivePath);
17
+
18
+ const storePath = getDriveStorePath(config.drivePath);
19
+ const bareRepoPath = join(storePath, `${projectName}.git`);
20
+
21
+ if (!existsSync(bareRepoPath)) {
22
+ throw new GitDriveError(`Project '${projectName}' not found on drive.`);
23
+ }
24
+
25
+ const targetPath = resolve(targetDir);
26
+ if (existsSync(targetPath)) {
27
+ throw new GitDriveError(`Directory already exists: ${targetPath}`);
28
+ }
29
+
30
+ git(`clone ${bareRepoPath} ${targetPath}`);
31
+
32
+ // Rename origin to drive so the remote stays consistent
33
+ git("remote rename origin drive", targetPath);
34
+
35
+ console.log(`Restored ${projectName} into ${targetPath}`);
36
+ }
@@ -0,0 +1,127 @@
1
+ import { existsSync, readdirSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ import { getDiskInfo } from "node-disk-info";
5
+ import { isGitRepo, getProjectName, getRemoteUrl } from "../git.js";
6
+
7
+ interface LinkConfig {
8
+ mountpoint: string;
9
+ repoName: string;
10
+ linkedAt: string;
11
+ }
12
+
13
+ function loadLinks(): Record<string, LinkConfig> {
14
+ const linksFile = join(homedir(), ".config", "git-drive", "links.json");
15
+ if (!existsSync(linksFile)) return {};
16
+ try {
17
+ return JSON.parse(readFileSync(linksFile, "utf-8"));
18
+ } catch {
19
+ return {};
20
+ }
21
+ }
22
+
23
+ function getGitDrivePath(mountpoint: string): string {
24
+ return join(mountpoint, ".git-drive");
25
+ }
26
+
27
+ export async function status(_args: string[]): Promise<void> {
28
+ console.log("Git Drive Status\n");
29
+
30
+ // Get all connected drives
31
+ let drives: any[] = [];
32
+ try {
33
+ drives = await getDiskInfo();
34
+ } catch (err) {
35
+ console.error("Error detecting drives:", err);
36
+ return;
37
+ }
38
+
39
+ // Filter to external/removable drives
40
+ const externalDrives = drives.filter((d: any) => {
41
+ const mp = d.mounted;
42
+ if (!mp) return false;
43
+ if (mp === "/" || mp === "100%") return false;
44
+
45
+ if (process.platform === "darwin") {
46
+ return mp.startsWith("/Volumes/") && !mp.startsWith("/Volumes/Recovery");
47
+ }
48
+
49
+ if (mp.startsWith("/sys") || mp.startsWith("/proc") || mp.startsWith("/run") || mp.startsWith("/snap") || mp.startsWith("/boot")) return false;
50
+ if (d.filesystem === "tmpfs" || d.filesystem === "devtmpfs" || d.filesystem === "udev" || d.filesystem === "overlay") return false;
51
+
52
+ return true;
53
+ });
54
+
55
+ // Load links
56
+ const links = loadLinks();
57
+ const linkEntries = Object.entries(links);
58
+
59
+ // Show connected drives with git-drive
60
+ console.log("=== Connected Drives ===\n");
61
+
62
+ if (externalDrives.length === 0) {
63
+ console.log("No external drives connected.\n");
64
+ } else {
65
+ for (const drive of externalDrives) {
66
+ const mp = drive.mounted;
67
+ const gitDrivePath = getGitDrivePath(mp);
68
+ const hasGitDrive = existsSync(gitDrivePath);
69
+
70
+ if (hasGitDrive) {
71
+ const entries = readdirSync(gitDrivePath).filter(n => n.endsWith(".git") || existsSync(join(gitDrivePath, n, "HEAD")));
72
+ console.log(`✓ ${mp}`);
73
+ console.log(` ${entries.length} repo${entries.length === 1 ? "" : "s"} backed up`);
74
+ } else {
75
+ console.log(`○ ${mp} (not initialized)`);
76
+ }
77
+ }
78
+ console.log();
79
+ }
80
+
81
+ // Show registered drives (from links)
82
+ console.log("=== Registered Repositories ===\n");
83
+
84
+ if (linkEntries.length === 0) {
85
+ console.log("No repositories linked to drives yet.");
86
+ console.log("Run 'git-drive link' to link a repository.\n");
87
+ } else {
88
+ for (const [localPath, link] of linkEntries) {
89
+ const stillConnected = existsSync(link.mountpoint);
90
+ const localExists = existsSync(localPath);
91
+
92
+ console.log(`${localPath}`);
93
+ console.log(` → ${link.mountpoint} (${link.repoName})`);
94
+ console.log(` Drive: ${stillConnected ? "connected" : "NOT CONNECTED"}`);
95
+ console.log(` Local: ${localExists ? "exists" : "NOT FOUND"}`);
96
+ console.log();
97
+ }
98
+ }
99
+
100
+ // Show current repo status if in a git repo
101
+ if (isGitRepo()) {
102
+ console.log("=== Current Repository ===\n");
103
+ const name = getProjectName();
104
+ const remoteUrl = getRemoteUrl("gd");
105
+
106
+ console.log(`Repository: ${name}`);
107
+ if (remoteUrl) {
108
+ console.log(`Remote 'gd': ${remoteUrl}`);
109
+
110
+ // Check if this repo is linked
111
+ const cwd = process.cwd();
112
+ const link = links[cwd];
113
+ if (link) {
114
+ console.log(`Linked to: ${link.mountpoint}`);
115
+ }
116
+ } else {
117
+ console.log(`No 'gd' remote configured.`);
118
+ console.log("Run 'git-drive link' to set up backup.");
119
+ }
120
+ console.log();
121
+ }
122
+
123
+ // Server status hint
124
+ console.log("=== Server ===\n");
125
+ console.log("Web UI: http://localhost:4483");
126
+ console.log("Run 'git-drive server' to start the web interface.\n");
127
+ }
@@ -0,0 +1,73 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
4
+ import { GitDriveError } from "./errors.js";
5
+
6
+ const CONFIG_DIR = join(homedir(), ".config", "git-drive");
7
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
8
+
9
+ export interface Config {
10
+ drivePath: string;
11
+ }
12
+
13
+ export function loadConfig(): Config | null {
14
+ if (!existsSync(CONFIG_FILE)) return null;
15
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
16
+ return JSON.parse(raw) as Config;
17
+ }
18
+
19
+ export function saveConfig(config: Config): void {
20
+ mkdirSync(CONFIG_DIR, { recursive: true });
21
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n");
22
+ }
23
+
24
+ export function requireConfig(): Config {
25
+ const config = loadConfig();
26
+ if (!config) {
27
+ throw new GitDriveError("No drive configured. Run: git drive init <path>");
28
+ }
29
+ return config;
30
+ }
31
+
32
+ export function assertDriveMounted(drivePath: string): void {
33
+ if (!existsSync(drivePath)) {
34
+ throw new GitDriveError(
35
+ `Drive not found at ${drivePath}. Is it connected?`
36
+ );
37
+ }
38
+ }
39
+
40
+ export function getDriveStorePath(drivePath: string): string {
41
+ return join(drivePath, "git-drive");
42
+ }
43
+
44
+ export interface LinkRegistry {
45
+ [localPath: string]: {
46
+ mountpoint: string;
47
+ repoName: string;
48
+ linkedAt: string;
49
+ };
50
+ }
51
+
52
+ const LINKS_FILE = join(CONFIG_DIR, "links.json");
53
+
54
+ export function loadLinks(): LinkRegistry {
55
+ if (!existsSync(LINKS_FILE)) return {};
56
+ try {
57
+ const raw = readFileSync(LINKS_FILE, "utf-8");
58
+ return JSON.parse(raw) as LinkRegistry;
59
+ } catch {
60
+ return {};
61
+ }
62
+ }
63
+
64
+ export function saveLink(localPath: string, mountpoint: string, repoName: string): void {
65
+ mkdirSync(CONFIG_DIR, { recursive: true });
66
+ const links = loadLinks();
67
+ links[localPath] = {
68
+ mountpoint,
69
+ repoName,
70
+ linkedAt: new Date().toISOString(),
71
+ };
72
+ writeFileSync(LINKS_FILE, JSON.stringify(links, null, 2) + "\n");
73
+ }
@@ -0,0 +1,23 @@
1
+ export class GitDriveError extends Error {
2
+ constructor(message: string) {
3
+ super(message);
4
+ this.name = "GitDriveError";
5
+ }
6
+ }
7
+
8
+ export function handleError(err: unknown): void {
9
+ if (err instanceof GitDriveError) {
10
+ console.error(`error: ${err.message}`);
11
+ } else if (err instanceof Error) {
12
+ const msg = err.message;
13
+ // execSync errors include stderr in the message
14
+ const stderrMatch = msg.match(/stderr:\s*([\s\S]*)/);
15
+ if (stderrMatch) {
16
+ console.error(`error: ${stderrMatch[1].trim()}`);
17
+ } else {
18
+ console.error(`error: ${msg}`);
19
+ }
20
+ } else {
21
+ console.error("An unexpected error occurred.");
22
+ }
23
+ }