git-syncr 1.0.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/dist/index.js +916 -0
  4. package/package.json +50 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Darren Allatt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,191 @@
1
+ # git-syncr
2
+
3
+ Automatically keep all your local git repos in sync with their remotes. Designed for developers who work across many repositories and want passive, reliable background syncing.
4
+
5
+ Works on **macOS** and **Linux**. Handles laptop sleep/wake gracefully — no syncs are ever missed.
6
+
7
+ ## Features
8
+
9
+ - Scans a directory for all git repos and pulls remote changes
10
+ - Interactive CLI with setup wizard and directory browser
11
+ - Background service that syncs on a configurable schedule (default: every 24 hours)
12
+ - Safe by design: uses `git pull --ff-only`, skips dirty working trees
13
+ - Desktop notifications with sync summary
14
+ - Detailed logging with per-repo status tracking
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install -g git-syncr
20
+ ```
21
+
22
+ Requires [Node.js](https://nodejs.org/) 18+ and [git](https://git-scm.com/).
23
+
24
+ ## Quick Start
25
+
26
+ ```bash
27
+ git-syncr
28
+ ```
29
+
30
+ On first run, the interactive setup wizard will guide you through:
31
+
32
+ 1. **Select your sync directory** — navigate with arrow keys to pick the folder containing your repos
33
+ 2. **Set sync interval** — how often to check for remote changes (default: 24h)
34
+ 3. **Enable notifications** — desktop alerts after each sync run
35
+
36
+ After setup, the background service is installed automatically and the main menu appears.
37
+
38
+ ## Usage
39
+
40
+ ### Interactive Mode
41
+
42
+ ```bash
43
+ git-syncr
44
+ ```
45
+
46
+ Opens the main menu with options to:
47
+
48
+ - **Sync now** — pull remote changes for all repos immediately
49
+ - **Sync (dry run)** — check what would be synced without pulling
50
+ - **Setup** — change your configuration
51
+ - **View last log** — see the most recent sync results
52
+ - **Quit** — exit with the option to keep or stop the background service
53
+
54
+ ### Non-Interactive Mode
55
+
56
+ For scripting or background service use:
57
+
58
+ ```bash
59
+ # Sync with terminal output
60
+ git-syncr --verbose
61
+
62
+ # Check for changes without pulling
63
+ git-syncr --dry-run --verbose
64
+
65
+ # Both flags
66
+ git-syncr --verbose --dry-run
67
+ ```
68
+
69
+ ## How It Works
70
+
71
+ ### Sync Logic
72
+
73
+ For each git repo found in your sync directory:
74
+
75
+ 1. **Check working tree** — if there are uncommitted changes, the repo is skipped (never risks your work)
76
+ 2. **Check branch** — skips repos in detached HEAD state or without a tracking branch
77
+ 3. **Fetch** — pulls the latest refs from the tracking remote
78
+ 4. **Compare** — checks if remote has new commits
79
+ 5. **Pull** — uses `--ff-only` to apply changes (fails safely if history has diverged)
80
+
81
+ ### Background Service
82
+
83
+ - **macOS**: Uses `launchd` with `StartInterval`. If your laptop is asleep when a sync is due, it runs immediately on wake.
84
+ - **Linux**: Uses `systemd` user timers with `Persistent=true`. Catches up on missed runs after resume.
85
+
86
+ ### Status Codes
87
+
88
+ Each repo gets one of these statuses per sync run:
89
+
90
+ | Status | Meaning |
91
+ |---|---|
92
+ | `[SYNCED]` | New commits pulled successfully |
93
+ | `[UNCHANGED]` | Already up to date |
94
+ | `[SKIPPED]` | Dirty working tree, detached HEAD, or no tracking branch |
95
+ | `[ERROR]` | Fetch failed, ff-only failed, or other git error |
96
+
97
+ ## Configuration
98
+
99
+ Config is stored at `~/.config/git-syncr/config.json`:
100
+
101
+ ```json
102
+ {
103
+ "syncDir": "/Users/you/Development",
104
+ "intervalSeconds": 86400,
105
+ "notificationsEnabled": true
106
+ }
107
+ ```
108
+
109
+ You can edit this file directly or use `git-syncr` to change settings interactively.
110
+
111
+ ### Interval Examples
112
+
113
+ | Value | Seconds |
114
+ |---|---|
115
+ | 1h | 3600 |
116
+ | 6h | 21600 |
117
+ | 12h | 43200 |
118
+ | 24h | 86400 |
119
+ | 7d | 604800 |
120
+
121
+ ## Logs
122
+
123
+ Logs are written to:
124
+
125
+ - **macOS**: `~/Library/Logs/git-syncr/git-syncr.log`
126
+ - **Linux**: `~/.local/share/git-syncr/logs/git-syncr.log`
127
+
128
+ Each run produces a block like:
129
+
130
+ ```
131
+ ========================================
132
+ git-syncr run: 2026-04-04T09:15:32.000Z
133
+ ========================================
134
+ [UNCHANGED] my-app (main)
135
+ [SYNCED] api-server (main) abc1234..def5678
136
+ [SKIPPED] docs (develop) -- dirty working tree
137
+ [ERROR] fork-repo (main) -- ff-only failed
138
+ ----------------------------------------
139
+ Summary: 1 synced, 1 unchanged, 1 skipped, 1 errored (4 total)
140
+ ========================================
141
+ ```
142
+
143
+ ## Troubleshooting
144
+
145
+ ### "No network connectivity"
146
+
147
+ The sync checks connectivity to github.com before running. If you're offline, it skips gracefully and retries on the next scheduled run.
148
+
149
+ ### Repos showing as [SKIPPED]
150
+
151
+ - **dirty working tree** — you have uncommitted changes. Commit or stash them.
152
+ - **detached HEAD** — check out a branch with `git checkout main`.
153
+ - **no tracking branch** — set one with `git branch -u origin/main`.
154
+
155
+ ### Repos showing as [ERROR]
156
+
157
+ - **fetch failed** — usually an auth issue. Check your git credentials or SSH keys.
158
+ - **ff-only failed** — local and remote have diverged. Manually merge or rebase to resolve.
159
+
160
+ ### Service not running
161
+
162
+ ```bash
163
+ # macOS — check if loaded
164
+ launchctl list | grep git-syncr
165
+
166
+ # Linux — check timer status
167
+ systemctl --user status git-syncr.timer
168
+ ```
169
+
170
+ Re-run `git-syncr` and select **Setup** to reinstall the service.
171
+
172
+ ### git not found by background service
173
+
174
+ The service includes `/opt/homebrew/bin`, `/usr/local/bin`, and `/usr/bin` in PATH. If your git is installed elsewhere, you may need to adjust the service configuration.
175
+
176
+ ## Uninstalling
177
+
178
+ Run `git-syncr`, select **Quit**, then choose **Quit & stop background sync** to remove the service.
179
+
180
+ To fully uninstall:
181
+
182
+ ```bash
183
+ npm uninstall -g git-syncr
184
+ rm -rf ~/.config/git-syncr
185
+ ```
186
+
187
+ Logs are preserved. Remove them manually if desired.
188
+
189
+ ## License
190
+
191
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,916 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/platform.ts
13
+ import os from "os";
14
+ import path from "path";
15
+ function getPlatform() {
16
+ switch (process.platform) {
17
+ case "darwin":
18
+ return "macos";
19
+ case "linux":
20
+ return "linux";
21
+ default:
22
+ throw new Error(`Unsupported platform: ${process.platform}. git-syncr supports macOS and Linux.`);
23
+ }
24
+ }
25
+ function getPaths() {
26
+ const home = os.homedir();
27
+ const platform = getPlatform();
28
+ const configDir = path.join(home, ".config", "git-syncr");
29
+ const configFile = path.join(configDir, "config.json");
30
+ if (platform === "macos") {
31
+ const logDir2 = path.join(home, "Library", "Logs", "git-syncr");
32
+ return {
33
+ configDir,
34
+ configFile,
35
+ logDir: logDir2,
36
+ lockFile: path.join(logDir2, ".git-syncr.lock"),
37
+ serviceDir: path.join(home, "Library", "LaunchAgents"),
38
+ serviceFile: path.join(home, "Library", "LaunchAgents", "com.user.git-syncr.plist")
39
+ };
40
+ }
41
+ const logDir = path.join(home, ".local", "share", "git-syncr", "logs");
42
+ return {
43
+ configDir,
44
+ configFile,
45
+ logDir,
46
+ lockFile: path.join(logDir, ".git-syncr.lock"),
47
+ serviceDir: path.join(home, ".config", "systemd", "user"),
48
+ serviceFile: path.join(home, ".config", "systemd", "user", "git-syncr.service")
49
+ };
50
+ }
51
+ var init_platform = __esm({
52
+ "src/platform.ts"() {
53
+ "use strict";
54
+ }
55
+ });
56
+
57
+ // src/config.ts
58
+ import fs from "fs";
59
+ function configExists() {
60
+ return fs.existsSync(paths.configFile);
61
+ }
62
+ function loadConfig() {
63
+ if (!configExists()) return null;
64
+ const raw = fs.readFileSync(paths.configFile, "utf-8");
65
+ return JSON.parse(raw);
66
+ }
67
+ function saveConfig(config) {
68
+ fs.mkdirSync(paths.configDir, { recursive: true });
69
+ fs.writeFileSync(paths.configFile, JSON.stringify(config, null, 2) + "\n");
70
+ }
71
+ var paths;
72
+ var init_config = __esm({
73
+ "src/config.ts"() {
74
+ "use strict";
75
+ init_platform();
76
+ paths = getPaths();
77
+ }
78
+ });
79
+
80
+ // src/templates/launchd.ts
81
+ function generatePlist(opts) {
82
+ return `<?xml version="1.0" encoding="UTF-8"?>
83
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
84
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
85
+ <plist version="1.0">
86
+ <dict>
87
+ <key>Label</key>
88
+ <string>com.user.git-syncr</string>
89
+
90
+ <key>ProgramArguments</key>
91
+ <array>
92
+ <string>${opts.nodePath}</string>
93
+ <string>${opts.scriptPath}</string>
94
+ </array>
95
+
96
+ <key>StartInterval</key>
97
+ <integer>${opts.intervalSeconds}</integer>
98
+
99
+ <key>EnvironmentVariables</key>
100
+ <dict>
101
+ <key>PATH</key>
102
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
103
+ <key>HOME</key>
104
+ <string>${opts.home}</string>
105
+ </dict>
106
+
107
+ <key>StandardOutPath</key>
108
+ <string>${opts.logDir}/launchd-stdout.log</string>
109
+ <key>StandardErrorPath</key>
110
+ <string>${opts.logDir}/launchd-stderr.log</string>
111
+
112
+ <key>RunAtLoad</key>
113
+ <false/>
114
+
115
+ <key>Nice</key>
116
+ <integer>10</integer>
117
+ </dict>
118
+ </plist>
119
+ `;
120
+ }
121
+ var init_launchd = __esm({
122
+ "src/templates/launchd.ts"() {
123
+ "use strict";
124
+ }
125
+ });
126
+
127
+ // src/templates/systemd.ts
128
+ function generateServiceUnit(opts) {
129
+ return `[Unit]
130
+ Description=git-syncr - Sync git repos with remotes
131
+
132
+ [Service]
133
+ Type=oneshot
134
+ ExecStart=${opts.nodePath} ${opts.scriptPath}
135
+ Environment=PATH=/usr/local/bin:/usr/bin:/bin
136
+
137
+ [Install]
138
+ WantedBy=default.target
139
+ `;
140
+ }
141
+ function generateTimerUnit(opts) {
142
+ return `[Unit]
143
+ Description=git-syncr timer - Run git-syncr periodically
144
+
145
+ [Timer]
146
+ OnBootSec=5min
147
+ OnUnitActiveSec=${opts.intervalSeconds}s
148
+ Persistent=true
149
+
150
+ [Install]
151
+ WantedBy=timers.target
152
+ `;
153
+ }
154
+ var init_systemd = __esm({
155
+ "src/templates/systemd.ts"() {
156
+ "use strict";
157
+ }
158
+ });
159
+
160
+ // src/service.ts
161
+ import fs5 from "fs";
162
+ import os2 from "os";
163
+ import { execSync } from "child_process";
164
+ function resolveScriptPath() {
165
+ try {
166
+ const result = execSync("which git-syncr", { encoding: "utf-8" }).trim();
167
+ return fs5.realpathSync(result);
168
+ } catch {
169
+ return process.argv[1];
170
+ }
171
+ }
172
+ function installService(config) {
173
+ const platform = getPlatform();
174
+ const paths2 = getPaths();
175
+ const nodePath = process.execPath;
176
+ const scriptPath = resolveScriptPath();
177
+ fs5.mkdirSync(paths2.logDir, { recursive: true });
178
+ if (platform === "macos") {
179
+ installLaunchd(nodePath, scriptPath, config, paths2);
180
+ } else {
181
+ installSystemd(nodePath, scriptPath, config, paths2);
182
+ }
183
+ }
184
+ function uninstallService() {
185
+ const platform = getPlatform();
186
+ const paths2 = getPaths();
187
+ if (platform === "macos") {
188
+ uninstallLaunchd(paths2);
189
+ } else {
190
+ uninstallSystemd(paths2);
191
+ }
192
+ }
193
+ function installLaunchd(nodePath, scriptPath, config, paths2) {
194
+ const plist = generatePlist({
195
+ nodePath,
196
+ scriptPath,
197
+ syncDir: config.syncDir,
198
+ logDir: paths2.logDir,
199
+ home: os2.homedir(),
200
+ intervalSeconds: config.intervalSeconds
201
+ });
202
+ fs5.mkdirSync(paths2.serviceDir, { recursive: true });
203
+ if (fs5.existsSync(paths2.serviceFile)) {
204
+ try {
205
+ execSync(`launchctl unload "${paths2.serviceFile}" 2>/dev/null`, { encoding: "utf-8" });
206
+ } catch {
207
+ }
208
+ }
209
+ fs5.writeFileSync(paths2.serviceFile, plist);
210
+ execSync(`launchctl load "${paths2.serviceFile}"`, { encoding: "utf-8" });
211
+ }
212
+ function uninstallLaunchd(paths2) {
213
+ if (fs5.existsSync(paths2.serviceFile)) {
214
+ try {
215
+ execSync(`launchctl unload "${paths2.serviceFile}" 2>/dev/null`, { encoding: "utf-8" });
216
+ } catch {
217
+ }
218
+ fs5.unlinkSync(paths2.serviceFile);
219
+ }
220
+ }
221
+ function installSystemd(nodePath, scriptPath, config, paths2) {
222
+ const serviceUnit = generateServiceUnit({ nodePath, scriptPath, intervalSeconds: config.intervalSeconds });
223
+ const timerUnit = generateTimerUnit({ nodePath, scriptPath, intervalSeconds: config.intervalSeconds });
224
+ fs5.mkdirSync(paths2.serviceDir, { recursive: true });
225
+ const timerFile = paths2.serviceFile.replace(".service", ".timer");
226
+ fs5.writeFileSync(paths2.serviceFile, serviceUnit);
227
+ fs5.writeFileSync(timerFile, timerUnit);
228
+ execSync("systemctl --user daemon-reload", { encoding: "utf-8" });
229
+ execSync("systemctl --user enable --now git-syncr.timer", { encoding: "utf-8" });
230
+ }
231
+ function uninstallSystemd(paths2) {
232
+ const timerFile = paths2.serviceFile.replace(".service", ".timer");
233
+ try {
234
+ execSync("systemctl --user disable --now git-syncr.timer 2>/dev/null", { encoding: "utf-8" });
235
+ } catch {
236
+ }
237
+ for (const f of [paths2.serviceFile, timerFile]) {
238
+ if (fs5.existsSync(f)) fs5.unlinkSync(f);
239
+ }
240
+ try {
241
+ execSync("systemctl --user daemon-reload", { encoding: "utf-8" });
242
+ } catch {
243
+ }
244
+ }
245
+ var init_service = __esm({
246
+ "src/service.ts"() {
247
+ "use strict";
248
+ init_platform();
249
+ init_launchd();
250
+ init_systemd();
251
+ }
252
+ });
253
+
254
+ // src/setup/directory-prompt.ts
255
+ import fs6 from "fs";
256
+ import path5 from "path";
257
+ import os3 from "os";
258
+ import { createPrompt, useState, useKeypress, isUpKey, isDownKey, isEnterKey, isBackspaceKey } from "@inquirer/core";
259
+ import pc from "picocolors";
260
+ var directoryPrompt;
261
+ var init_directory_prompt = __esm({
262
+ "src/setup/directory-prompt.ts"() {
263
+ "use strict";
264
+ directoryPrompt = createPrompt(
265
+ (config, done) => {
266
+ const [currentDir, setCurrentDir] = useState(config.default ?? os3.homedir());
267
+ const [cursor, setCursor] = useState(0);
268
+ const [selected, setSelected] = useState(false);
269
+ let entries;
270
+ try {
271
+ entries = fs6.readdirSync(currentDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".")).map((d) => d.name).sort((a, b) => a.localeCompare(b));
272
+ } catch {
273
+ entries = [];
274
+ }
275
+ const totalItems = entries.length + 1;
276
+ useKeypress((key) => {
277
+ if (selected) return;
278
+ if (isUpKey(key)) {
279
+ setCursor((cursor - 1 + totalItems) % totalItems);
280
+ } else if (isDownKey(key)) {
281
+ setCursor((cursor + 1) % totalItems);
282
+ } else if (isEnterKey(key) || key.name === "right") {
283
+ if (cursor === 0) {
284
+ setSelected(true);
285
+ done(currentDir);
286
+ } else {
287
+ const target = path5.join(currentDir, entries[cursor - 1]);
288
+ setCurrentDir(target);
289
+ setCursor(0);
290
+ }
291
+ } else if (isBackspaceKey(key) || key.name === "left") {
292
+ const parent = path5.dirname(currentDir);
293
+ if (parent !== currentDir) {
294
+ setCurrentDir(parent);
295
+ setCursor(0);
296
+ }
297
+ }
298
+ });
299
+ if (selected) {
300
+ return `${pc.green("?")} ${config.message} ${pc.cyan(currentDir)}`;
301
+ }
302
+ const lines = [];
303
+ lines.push(`${pc.green("?")} ${config.message}`);
304
+ lines.push(pc.dim(` ${currentDir}`));
305
+ lines.push("");
306
+ if (cursor === 0) {
307
+ lines.push(` ${pc.cyan(">")} ${pc.bold(pc.green("[ Select this directory ]"))}`);
308
+ } else {
309
+ lines.push(` ${pc.dim("[ Select this directory ]")}`);
310
+ }
311
+ const maxVisible = 15;
312
+ const startIdx = Math.max(0, Math.min(cursor - 1 - Math.floor(maxVisible / 2), entries.length - maxVisible));
313
+ const endIdx = Math.min(entries.length, startIdx + maxVisible);
314
+ if (startIdx > 0) {
315
+ lines.push(pc.dim(` ... ${startIdx} more above`));
316
+ }
317
+ for (let i = startIdx; i < endIdx; i++) {
318
+ const itemIdx = i + 1;
319
+ if (itemIdx === cursor) {
320
+ lines.push(` ${pc.cyan(">")} ${pc.bold(entries[i])}/`);
321
+ } else {
322
+ lines.push(` ${entries[i]}/`);
323
+ }
324
+ }
325
+ if (endIdx < entries.length) {
326
+ lines.push(pc.dim(` ... ${entries.length - endIdx} more below`));
327
+ }
328
+ lines.push("");
329
+ lines.push(pc.dim(" Use arrow keys to navigate, Enter to select, Backspace to go up"));
330
+ return lines.join("\n");
331
+ }
332
+ );
333
+ }
334
+ });
335
+
336
+ // src/setup/wizard.ts
337
+ var wizard_exports = {};
338
+ __export(wizard_exports, {
339
+ runWizard: () => runWizard
340
+ });
341
+ import os4 from "os";
342
+ import path6 from "path";
343
+ import { input, confirm } from "@inquirer/prompts";
344
+ import pc2 from "picocolors";
345
+ function parseInterval(value) {
346
+ const match = value.trim().match(/^(\d+)\s*(h|d|m|s)?$/i);
347
+ if (!match) return null;
348
+ const num = parseInt(match[1], 10);
349
+ const unit = (match[2] ?? "h").toLowerCase();
350
+ switch (unit) {
351
+ case "s":
352
+ return num;
353
+ case "m":
354
+ return num * 60;
355
+ case "h":
356
+ return num * 3600;
357
+ case "d":
358
+ return num * 86400;
359
+ default:
360
+ return null;
361
+ }
362
+ }
363
+ function formatInterval(seconds) {
364
+ if (seconds % 86400 === 0) return `${seconds / 86400}d`;
365
+ if (seconds % 3600 === 0) return `${seconds / 3600}h`;
366
+ if (seconds % 60 === 0) return `${seconds / 60}m`;
367
+ return `${seconds}s`;
368
+ }
369
+ async function runWizard() {
370
+ const existing = loadConfig();
371
+ const isReconfig = existing !== null;
372
+ console.log("");
373
+ console.log(pc2.bold(" git-syncr setup"));
374
+ console.log(pc2.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
375
+ if (isReconfig) {
376
+ console.log("");
377
+ console.log(pc2.dim(" Current configuration:"));
378
+ console.log(` Sync directory: ${pc2.cyan(existing.syncDir)}`);
379
+ console.log(` Sync interval: ${pc2.cyan(formatInterval(existing.intervalSeconds))}`);
380
+ console.log(` Notifications: ${pc2.cyan(existing.notificationsEnabled ? "on" : "off")}`);
381
+ console.log("");
382
+ }
383
+ let syncDir = existing?.syncDir ?? path6.join(os4.homedir(), "Development");
384
+ let intervalSeconds = existing?.intervalSeconds ?? 86400;
385
+ let notificationsEnabled = existing?.notificationsEnabled ?? true;
386
+ if (isReconfig) {
387
+ const changeSyncDir = await confirm({
388
+ message: `Change sync directory? (currently ${pc2.cyan(syncDir)})`,
389
+ default: false
390
+ });
391
+ if (changeSyncDir) {
392
+ syncDir = await directoryPrompt({
393
+ message: "Select sync directory:",
394
+ default: syncDir
395
+ });
396
+ }
397
+ } else {
398
+ syncDir = await directoryPrompt({
399
+ message: "Select the directory to sync:",
400
+ default: syncDir
401
+ });
402
+ }
403
+ if (isReconfig) {
404
+ const changeInterval = await confirm({
405
+ message: `Change sync interval? (currently ${pc2.cyan(formatInterval(intervalSeconds))})`,
406
+ default: false
407
+ });
408
+ if (changeInterval) {
409
+ intervalSeconds = await promptInterval(intervalSeconds);
410
+ }
411
+ } else {
412
+ intervalSeconds = await promptInterval(intervalSeconds);
413
+ }
414
+ if (isReconfig) {
415
+ const changeNotify = await confirm({
416
+ message: `Change notifications? (currently ${pc2.cyan(notificationsEnabled ? "on" : "off")})`,
417
+ default: false
418
+ });
419
+ if (changeNotify) {
420
+ notificationsEnabled = await confirm({
421
+ message: "Enable desktop notifications?",
422
+ default: notificationsEnabled
423
+ });
424
+ }
425
+ } else {
426
+ notificationsEnabled = await confirm({
427
+ message: "Enable desktop notifications?",
428
+ default: true
429
+ });
430
+ }
431
+ const config = { syncDir, intervalSeconds, notificationsEnabled };
432
+ saveConfig(config);
433
+ console.log("");
434
+ console.log(pc2.dim(" Installing background service..."));
435
+ try {
436
+ installService(config);
437
+ console.log(pc2.green(" Service installed successfully."));
438
+ } catch (err) {
439
+ const msg = err instanceof Error ? err.message : String(err);
440
+ console.log(pc2.yellow(` Could not install service: ${msg}`));
441
+ console.log(pc2.dim(" You can run git-syncr manually or try again later."));
442
+ }
443
+ console.log("");
444
+ console.log(pc2.green(" Setup complete!"));
445
+ console.log("");
446
+ }
447
+ async function promptInterval(defaultSeconds) {
448
+ const result = await input({
449
+ message: "Sync interval (e.g., 1h, 6h, 12h, 24h, 7d):",
450
+ default: formatInterval(defaultSeconds),
451
+ validate: (value) => {
452
+ const parsed = parseInterval(value);
453
+ if (parsed === null || parsed < 60) {
454
+ return "Enter a valid interval (e.g., 1h, 6h, 24h, 7d). Minimum 1m.";
455
+ }
456
+ return true;
457
+ }
458
+ });
459
+ return parseInterval(result);
460
+ }
461
+ var init_wizard = __esm({
462
+ "src/setup/wizard.ts"() {
463
+ "use strict";
464
+ init_config();
465
+ init_service();
466
+ init_directory_prompt();
467
+ }
468
+ });
469
+
470
+ // src/cli.ts
471
+ init_config();
472
+ import fs7 from "fs";
473
+ import { select } from "@inquirer/prompts";
474
+ import pc3 from "picocolors";
475
+
476
+ // src/sync.ts
477
+ init_config();
478
+ init_platform();
479
+ import fs4 from "fs";
480
+ import path4 from "path";
481
+
482
+ // src/logger.ts
483
+ import fs2 from "fs";
484
+ import path2 from "path";
485
+ var Logger = class {
486
+ constructor(logFile, verbose) {
487
+ this.logFile = logFile;
488
+ this.verbose = verbose;
489
+ fs2.mkdirSync(path2.dirname(logFile), { recursive: true });
490
+ }
491
+ logFile;
492
+ verbose;
493
+ log(message) {
494
+ fs2.appendFileSync(this.logFile, message + "\n");
495
+ if (this.verbose) {
496
+ console.log(message);
497
+ }
498
+ }
499
+ };
500
+
501
+ // src/lock.ts
502
+ import fs3 from "fs";
503
+ import path3 from "path";
504
+ function acquireLock(lockPath) {
505
+ fs3.mkdirSync(path3.dirname(lockPath), { recursive: true });
506
+ if (fs3.existsSync(lockPath)) {
507
+ const raw = fs3.readFileSync(lockPath, "utf-8").trim();
508
+ const pid = parseInt(raw, 10);
509
+ if (!isNaN(pid)) {
510
+ try {
511
+ process.kill(pid, 0);
512
+ return false;
513
+ } catch {
514
+ fs3.unlinkSync(lockPath);
515
+ }
516
+ } else {
517
+ fs3.unlinkSync(lockPath);
518
+ }
519
+ }
520
+ fs3.writeFileSync(lockPath, String(process.pid));
521
+ return true;
522
+ }
523
+ function releaseLock(lockPath) {
524
+ try {
525
+ fs3.unlinkSync(lockPath);
526
+ } catch {
527
+ }
528
+ }
529
+
530
+ // src/network.ts
531
+ async function checkNetwork() {
532
+ try {
533
+ const response = await fetch("https://github.com", {
534
+ method: "HEAD",
535
+ signal: AbortSignal.timeout(1e4)
536
+ });
537
+ return response.ok || response.status === 301;
538
+ } catch {
539
+ return false;
540
+ }
541
+ }
542
+
543
+ // src/repo.ts
544
+ import { simpleGit } from "simple-git";
545
+ async function processRepo(repoPath, repoName, dryRun) {
546
+ const git = simpleGit(repoPath, {
547
+ timeout: { block: 3e4 }
548
+ });
549
+ process.env["GIT_HTTP_LOW_SPEED_LIMIT"] = "1000";
550
+ process.env["GIT_HTTP_LOW_SPEED_TIME"] = "15";
551
+ try {
552
+ const status = await git.status();
553
+ if (!status.isClean()) {
554
+ return { status: "SKIPPED", name: repoName, detail: "dirty working tree" };
555
+ }
556
+ let branch;
557
+ try {
558
+ branch = (await git.raw(["symbolic-ref", "--short", "HEAD"])).trim();
559
+ } catch {
560
+ return { status: "SKIPPED", name: repoName, detail: "detached HEAD" };
561
+ }
562
+ if (!branch) {
563
+ return { status: "SKIPPED", name: repoName, detail: "detached HEAD" };
564
+ }
565
+ let upstream;
566
+ try {
567
+ upstream = (await git.raw(["rev-parse", "--abbrev-ref", "@{upstream}"])).trim();
568
+ } catch {
569
+ return { status: "SKIPPED", name: `${repoName} (${branch})`, detail: "no tracking branch" };
570
+ }
571
+ const remoteName = upstream.split("/")[0];
572
+ const nameWithBranch = `${repoName} (${branch})`;
573
+ try {
574
+ await git.fetch(remoteName);
575
+ } catch (err) {
576
+ const msg = err instanceof Error ? err.message : String(err);
577
+ return { status: "ERROR", name: nameWithBranch, detail: `fetch failed: ${msg}` };
578
+ }
579
+ const localSha = (await git.revparse(["HEAD"])).trim();
580
+ const remoteSha = (await git.revparse([upstream])).trim();
581
+ if (localSha === remoteSha) {
582
+ return { status: "UNCHANGED", name: nameWithBranch };
583
+ }
584
+ const shortLocal = localSha.slice(0, 7);
585
+ const shortRemote = remoteSha.slice(0, 7);
586
+ if (dryRun) {
587
+ return { status: "WOULD_SYNC", name: nameWithBranch, detail: `${shortLocal}..${shortRemote}` };
588
+ }
589
+ try {
590
+ await git.pull(["--ff-only"]);
591
+ } catch (err) {
592
+ const msg = err instanceof Error ? err.message : String(err);
593
+ return { status: "ERROR", name: nameWithBranch, detail: `ff-only failed: ${msg}` };
594
+ }
595
+ return { status: "SYNCED", name: nameWithBranch, detail: `${shortLocal}..${shortRemote}` };
596
+ } catch (err) {
597
+ const msg = err instanceof Error ? err.message : String(err);
598
+ return { status: "ERROR", name: repoName, detail: msg };
599
+ }
600
+ }
601
+
602
+ // src/notify.ts
603
+ init_platform();
604
+ import { execFile } from "child_process";
605
+ function sendNotification(title, subtitle, body) {
606
+ const platform = getPlatform();
607
+ try {
608
+ if (platform === "macos") {
609
+ const script = `display notification "${body}" with title "${title}" subtitle "${subtitle}"`;
610
+ execFile("osascript", ["-e", script], (err) => {
611
+ if (err) {
612
+ }
613
+ });
614
+ } else {
615
+ execFile("notify-send", [title, `${subtitle}
616
+ ${body}`], (err) => {
617
+ if (err) {
618
+ }
619
+ });
620
+ }
621
+ } catch {
622
+ }
623
+ }
624
+
625
+ // src/sync.ts
626
+ async function runSync(opts) {
627
+ const config = loadConfig();
628
+ if (!config) {
629
+ return { synced: 0, unchanged: 0, skipped: 0, errored: 0, total: 0, abortReason: "No configuration found." };
630
+ }
631
+ const paths2 = getPaths();
632
+ const logFile = path4.join(paths2.logDir, "git-syncr.log");
633
+ const logger = new Logger(logFile, opts.verbose);
634
+ if (!acquireLock(paths2.lockFile)) {
635
+ logger.log("Another git-syncr is running. Exiting.");
636
+ sendNotification("git-syncr", "Already running", "Another instance is running.");
637
+ return { synced: 0, unchanged: 0, skipped: 0, errored: 0, total: 0, abortReason: "Another instance is running." };
638
+ }
639
+ try {
640
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
641
+ logger.log("========================================");
642
+ logger.log(`git-syncr run: ${timestamp}`);
643
+ if (opts.dryRun) {
644
+ logger.log("MODE: dry-run (no pulls)");
645
+ }
646
+ logger.log("========================================");
647
+ const online = await checkNetwork();
648
+ if (!online) {
649
+ logger.log("No network connectivity. Aborting.");
650
+ sendNotification("git-syncr", "No network", "Could not reach github.com");
651
+ logger.log("========================================");
652
+ return { synced: 0, unchanged: 0, skipped: 0, errored: 0, total: 0, abortReason: "No network connectivity." };
653
+ }
654
+ const repos = discoverRepos(config.syncDir);
655
+ let synced = 0;
656
+ let unchanged = 0;
657
+ let skipped = 0;
658
+ let errored = 0;
659
+ for (const repoPath of repos) {
660
+ const repoName = path4.basename(repoPath);
661
+ const result = await processRepo(repoPath, repoName, opts.dryRun);
662
+ switch (result.status) {
663
+ case "SYNCED":
664
+ logger.log(`[SYNCED] ${result.name} ${result.detail ?? ""}`);
665
+ synced++;
666
+ break;
667
+ case "WOULD_SYNC":
668
+ logger.log(`[WOULD_SYNC] ${result.name} ${result.detail ?? ""}`);
669
+ synced++;
670
+ break;
671
+ case "UNCHANGED":
672
+ logger.log(`[UNCHANGED] ${result.name}`);
673
+ unchanged++;
674
+ break;
675
+ case "SKIPPED":
676
+ logger.log(`[SKIPPED] ${result.name} -- ${result.detail ?? ""}`);
677
+ skipped++;
678
+ break;
679
+ case "ERROR":
680
+ logger.log(`[ERROR] ${result.name} -- ${result.detail ?? ""}`);
681
+ errored++;
682
+ break;
683
+ }
684
+ }
685
+ const total = synced + unchanged + skipped + errored;
686
+ logger.log("----------------------------------------");
687
+ const syncLabel = opts.dryRun ? "would sync" : "synced";
688
+ const summary = `${synced} ${syncLabel}, ${unchanged} unchanged, ${skipped} skipped, ${errored} errored (${total} total)`;
689
+ logger.log(`Summary: ${summary}`);
690
+ logger.log("========================================");
691
+ sendNotification(
692
+ "git-syncr",
693
+ `${total} repos processed`,
694
+ `${synced} ${syncLabel}, ${unchanged} unchanged, ${skipped} skipped, ${errored} errored`
695
+ );
696
+ return { synced, unchanged, skipped, errored, total };
697
+ } finally {
698
+ releaseLock(paths2.lockFile);
699
+ }
700
+ }
701
+ function discoverRepos(syncDir) {
702
+ if (!fs4.existsSync(syncDir)) {
703
+ return [];
704
+ }
705
+ const entries = fs4.readdirSync(syncDir, { withFileTypes: true });
706
+ const repos = [];
707
+ for (const entry of entries) {
708
+ if (!entry.isDirectory()) continue;
709
+ const fullPath = path4.join(syncDir, entry.name);
710
+ const gitDir = path4.join(fullPath, ".git");
711
+ if (fs4.existsSync(gitDir)) {
712
+ repos.push(fullPath);
713
+ }
714
+ }
715
+ return repos.sort((a, b) => path4.basename(a).localeCompare(path4.basename(b)));
716
+ }
717
+
718
+ // src/cli.ts
719
+ init_service();
720
+ init_platform();
721
+ function printUsage() {
722
+ console.log(`
723
+ Usage: git-syncr [options]
724
+
725
+ Options:
726
+ --help Show this help message
727
+
728
+ When run without options, opens the interactive menu.
729
+ `);
730
+ }
731
+ function printHeader() {
732
+ console.log("");
733
+ console.log(pc3.bold(" git-syncr"));
734
+ console.log(pc3.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
735
+ }
736
+ function printStatus() {
737
+ const config = loadConfig();
738
+ const paths2 = getPaths();
739
+ if (config) {
740
+ console.log("");
741
+ console.log(` Sync directory: ${pc3.cyan(config.syncDir)}`);
742
+ console.log(` Sync interval: ${pc3.cyan(formatInterval2(config.intervalSeconds))}`);
743
+ console.log(` Notifications: ${pc3.cyan(config.notificationsEnabled ? "on" : "off")}`);
744
+ console.log(` Logs: ${pc3.dim(paths2.logDir)}`);
745
+ } else {
746
+ console.log("");
747
+ console.log(pc3.yellow(' Not configured. Select "Setup" to get started.'));
748
+ }
749
+ console.log("");
750
+ }
751
+ function formatInterval2(seconds) {
752
+ if (seconds % 86400 === 0) return `${seconds / 86400}d`;
753
+ if (seconds % 3600 === 0) return `${seconds / 3600}h`;
754
+ if (seconds % 60 === 0) return `${seconds / 60}m`;
755
+ return `${seconds}s`;
756
+ }
757
+ async function mainMenu() {
758
+ if (!configExists()) {
759
+ printHeader();
760
+ console.log("");
761
+ console.log(pc3.dim(" Welcome! Let's set up git-syncr."));
762
+ const { runWizard: runWizard2 } = await Promise.resolve().then(() => (init_wizard(), wizard_exports));
763
+ await runWizard2();
764
+ }
765
+ while (true) {
766
+ printHeader();
767
+ printStatus();
768
+ const action = await select({
769
+ message: "What would you like to do?",
770
+ choices: [
771
+ { name: "Sync now", value: "sync", description: "Pull remote changes for all repos" },
772
+ { name: "Sync (dry run)", value: "dry-run", description: "Check for changes without pulling" },
773
+ { name: "Setup", value: "setup", description: "Change sync directory, interval, or notifications" },
774
+ { name: "View last log", value: "log", description: "Show the most recent sync log" },
775
+ { name: "Quit", value: "quit", description: "Exit git-syncr" }
776
+ ]
777
+ });
778
+ switch (action) {
779
+ case "sync": {
780
+ console.log("");
781
+ const result = await runSync({ verbose: true, dryRun: false });
782
+ if (result.abortReason) {
783
+ console.log(pc3.yellow(`
784
+ ${result.abortReason}`));
785
+ }
786
+ console.log("");
787
+ await pressEnterToContinue();
788
+ break;
789
+ }
790
+ case "dry-run": {
791
+ console.log("");
792
+ const result = await runSync({ verbose: true, dryRun: true });
793
+ if (result.abortReason) {
794
+ console.log(pc3.yellow(`
795
+ ${result.abortReason}`));
796
+ }
797
+ console.log("");
798
+ await pressEnterToContinue();
799
+ break;
800
+ }
801
+ case "setup": {
802
+ const { runWizard: runWizard2 } = await Promise.resolve().then(() => (init_wizard(), wizard_exports));
803
+ await runWizard2();
804
+ break;
805
+ }
806
+ case "log": {
807
+ showLastLog();
808
+ await pressEnterToContinue();
809
+ break;
810
+ }
811
+ case "quit": {
812
+ await handleQuit();
813
+ return;
814
+ }
815
+ }
816
+ }
817
+ }
818
+ async function handleQuit() {
819
+ const action = await select({
820
+ message: "How would you like to quit?",
821
+ choices: [
822
+ {
823
+ name: "Quit & keep syncing in background",
824
+ value: "keep",
825
+ description: "Background service continues to sync on schedule"
826
+ },
827
+ {
828
+ name: "Quit & stop background sync",
829
+ value: "stop",
830
+ description: "Uninstall the background service"
831
+ },
832
+ {
833
+ name: "Cancel",
834
+ value: "cancel",
835
+ description: "Return to main menu"
836
+ }
837
+ ]
838
+ });
839
+ switch (action) {
840
+ case "keep":
841
+ console.log("");
842
+ console.log(pc3.dim(" Background sync will continue on schedule."));
843
+ console.log("");
844
+ process.exit(0);
845
+ break;
846
+ case "stop":
847
+ try {
848
+ uninstallService();
849
+ console.log("");
850
+ console.log(pc3.dim(" Background service stopped and removed."));
851
+ console.log(pc3.dim(" Run git-syncr again to re-enable."));
852
+ console.log("");
853
+ } catch (err) {
854
+ const msg = err instanceof Error ? err.message : String(err);
855
+ console.log(pc3.yellow(`
856
+ Could not stop service: ${msg}`));
857
+ console.log("");
858
+ }
859
+ process.exit(0);
860
+ break;
861
+ case "cancel":
862
+ break;
863
+ }
864
+ }
865
+ function showLastLog() {
866
+ const paths2 = getPaths();
867
+ const logFile = `${paths2.logDir}/git-syncr.log`;
868
+ try {
869
+ const content = fs7.readFileSync(logFile, "utf-8");
870
+ const lines = content.trimEnd().split("\n");
871
+ let startIdx = lines.length - 1;
872
+ let foundEnd = false;
873
+ for (let i = lines.length - 1; i >= 0; i--) {
874
+ if (lines[i].startsWith("====")) {
875
+ if (foundEnd) {
876
+ startIdx = i;
877
+ break;
878
+ }
879
+ foundEnd = true;
880
+ }
881
+ }
882
+ console.log("");
883
+ for (let i = startIdx; i < lines.length; i++) {
884
+ console.log(` ${lines[i]}`);
885
+ }
886
+ console.log("");
887
+ } catch {
888
+ console.log("");
889
+ console.log(pc3.dim(" No log file found. Run a sync first."));
890
+ console.log("");
891
+ }
892
+ }
893
+ async function pressEnterToContinue() {
894
+ const { input: input2 } = await import("@inquirer/prompts");
895
+ await input2({ message: pc3.dim("Press Enter to continue...") });
896
+ }
897
+ async function run(args) {
898
+ if (args.includes("--help")) {
899
+ printUsage();
900
+ return;
901
+ }
902
+ if (args.includes("--verbose") || args.includes("--dry-run")) {
903
+ if (!configExists()) {
904
+ console.error('No configuration found. Run "git-syncr" to set up.');
905
+ process.exit(1);
906
+ }
907
+ const verbose = args.includes("--verbose");
908
+ const dryRun = args.includes("--dry-run");
909
+ const result = await runSync({ verbose, dryRun });
910
+ process.exit(result.errored > 0 ? 1 : 0);
911
+ }
912
+ await mainMenu();
913
+ }
914
+
915
+ // src/index.ts
916
+ run(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "git-syncr",
3
+ "version": "1.0.0",
4
+ "description": "Automatically sync all git repos in a directory with their remotes",
5
+ "type": "module",
6
+ "bin": {
7
+ "git-syncr": "dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsup",
11
+ "dev": "tsx src/index.ts",
12
+ "postinstall": "node -e \"console.log('\\n Run \\x1b[1mgit-syncr\\x1b[0m to complete setup.\\n');\""
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "keywords": [
21
+ "git",
22
+ "sync",
23
+ "repository",
24
+ "auto-sync",
25
+ "launchd",
26
+ "systemd"
27
+ ],
28
+ "author": "Darren Allatt",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/Darren-A11att/git-sync.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/Darren-A11att/git-sync/issues"
36
+ },
37
+ "homepage": "https://github.com/Darren-A11att/git-sync#readme",
38
+ "dependencies": {
39
+ "@inquirer/core": "^11.1.7",
40
+ "@inquirer/prompts": "^8.3.2",
41
+ "picocolors": "^1.1.1",
42
+ "simple-git": "^3.33.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^22.0.0",
46
+ "tsup": "^8.5.1",
47
+ "tsx": "^4.19.0",
48
+ "typescript": "^5.7.0"
49
+ }
50
+ }