island-bridge 1.0.0 → 2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gong1414
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 CHANGED
@@ -21,9 +21,28 @@ npx island-bridge push
21
21
  - `rsync` installed on both local and remote machines
22
22
  - SSH access to remote server (uses system `~/.ssh/config`)
23
23
 
24
+ ## Quick Start
25
+
26
+ ```bash
27
+ # Create config interactively
28
+ island-bridge init
29
+
30
+ # Or create config via flags (AI/script friendly)
31
+ island-bridge init --host 192.168.1.100 --user deploy --paths "/var/www/app,/etc/nginx/conf.d"
32
+
33
+ # Check environment is ready
34
+ island-bridge status
35
+
36
+ # Pull remote folders to local
37
+ island-bridge pull
38
+
39
+ # Push local changes back
40
+ island-bridge push
41
+ ```
42
+
24
43
  ## Usage
25
44
 
26
- 1. Create `island-bridge.json` in your working directory:
45
+ 1. Create `island-bridge.json` in your working directory (or run `island-bridge init`):
27
46
 
28
47
  ```json
29
48
  {
@@ -59,15 +78,254 @@ This creates local subdirectories matching the remote folder names:
59
78
  island-bridge push
60
79
  ```
61
80
 
81
+ ## Commands
82
+
83
+ | Command | Description |
84
+ |---------|-------------|
85
+ | `pull` | Pull remote folders to local directory |
86
+ | `push` | Push local folders to remote server |
87
+ | `watch` | Watch local folders and auto-push on changes |
88
+ | `diff` | Preview changes without syncing |
89
+ | `history` | Show sync history |
90
+ | `init` | Create config file interactively or via flags |
91
+ | `status` | Show config, SSH, rsync, and paths status |
92
+ | `backup` | Manage sync backups (list, restore, clean) |
93
+
94
+ ## Options
95
+
96
+ | Option | Description |
97
+ |--------|-------------|
98
+ | `-n, --dry-run` | Preview sync without making changes |
99
+ | `-v, --verbose` | Show detailed output |
100
+ | `-q, --quiet` | Suppress output (exit code only) |
101
+ | `-c, --config <path>` | Use specific config file |
102
+ | `--env <name>` | Use named profile from config |
103
+ | `--bwlimit <KB/s>` | Limit transfer bandwidth |
104
+ | `-s, --select` | Interactively select folders to sync |
105
+ | `--path <name>` | Sync specific folder by name (repeatable) |
106
+ | `--json` | Output in JSON format (for scripts/AI) |
107
+ | `--no-backup` | Skip backup for this sync |
108
+ | `-V, --version` | Show version |
109
+ | `-h, --help` | Show help |
110
+
111
+ ## Backup & Restore
112
+
113
+ Backups are **enabled by default**. Before each sync, files that will be overwritten are automatically saved to a timestamped backup directory.
114
+
115
+ ```bash
116
+ # List available backups
117
+ island-bridge backup list
118
+
119
+ # Restore a specific backup
120
+ island-bridge backup restore 2026-04-07T14-30-00
121
+
122
+ # Clean old backups, keep 5 most recent
123
+ island-bridge backup clean --keep 5
124
+ ```
125
+
126
+ ### Backup Config
127
+
128
+ ```json
129
+ {
130
+ "backup": {
131
+ "enabled": true,
132
+ "maxCount": 10,
133
+ "localDir": ".island-bridge-backups",
134
+ "remoteDir": "~/.island-bridge-backups"
135
+ }
136
+ }
137
+ ```
138
+
139
+ Use `--no-backup` to skip backup for a single sync operation.
140
+
141
+ ## JSON Output (AI/Script Friendly)
142
+
143
+ Add `--json` to any command for structured JSON output — no colors, no progress bars, no interactive prompts:
144
+
145
+ ```bash
146
+ # Check environment before syncing
147
+ island-bridge status --json
148
+
149
+ # Sync and parse results programmatically
150
+ island-bridge pull --json
151
+
152
+ # Create config non-interactively
153
+ island-bridge init --json --host example.com --user deploy --paths "/var/www/app"
154
+ ```
155
+
156
+ Example JSON output:
157
+
158
+ ```json
159
+ {
160
+ "version": "2.0.0",
161
+ "command": "pull",
162
+ "success": true,
163
+ "results": [
164
+ {
165
+ "folder": "app",
166
+ "remotePath": "/var/www/app",
167
+ "success": true,
168
+ "changes": [
169
+ { "type": "add", "file": "index.js" },
170
+ { "type": "delete", "file": "old.css" }
171
+ ]
172
+ }
173
+ ],
174
+ "messages": [],
175
+ "errors": []
176
+ }
177
+ ```
178
+
179
+ ## Advanced Config
180
+
181
+ ```json
182
+ {
183
+ "remote": {
184
+ "host": "192.168.1.100",
185
+ "user": "deploy",
186
+ "paths": ["/var/www/app", "/etc/nginx/conf.d"]
187
+ },
188
+ "exclude": ["node_modules", ".DS_Store", "*.log"],
189
+ "bwlimit": 1000,
190
+ "backup": {
191
+ "enabled": true,
192
+ "maxCount": 10
193
+ },
194
+ "hooks": {
195
+ "beforeSync": "echo 'Starting sync...'",
196
+ "afterSync": "pm2 restart app"
197
+ },
198
+ "profiles": {
199
+ "staging": {
200
+ "remote": {
201
+ "host": "staging.example.com",
202
+ "user": "deploy",
203
+ "paths": ["/var/www/app"]
204
+ }
205
+ },
206
+ "production": {
207
+ "remote": {
208
+ "host": "prod.example.com",
209
+ "user": "admin",
210
+ "paths": ["/var/www/app"]
211
+ }
212
+ }
213
+ }
214
+ }
215
+ ```
216
+
217
+ ### Exclude Rules
218
+
219
+ Custom exclude patterns are applied in addition to `.gitignore` rules:
220
+
221
+ ```json
222
+ {
223
+ "exclude": ["node_modules", "*.log", ".env"]
224
+ }
225
+ ```
226
+
227
+ ### Hooks
228
+
229
+ Run shell commands before and after sync:
230
+
231
+ ```json
232
+ {
233
+ "hooks": {
234
+ "beforeSync": "npm run build",
235
+ "afterSync": "ssh deploy@server 'pm2 restart app'"
236
+ }
237
+ }
238
+ ```
239
+
240
+ ### Multi-Environment Profiles
241
+
242
+ Switch between environments using `--env`:
243
+
244
+ ```bash
245
+ island-bridge pull --env staging
246
+ island-bridge push --env production
247
+ ```
248
+
249
+ Profile settings are merged over the base config.
250
+
251
+ ### Config File Search
252
+
253
+ The config file is searched starting from the current directory upward (like `.gitignore`). Use `--config` to specify an explicit path:
254
+
255
+ ```bash
256
+ island-bridge pull --config /path/to/my-config.json
257
+ ```
258
+
259
+ ## Examples
260
+
261
+ ```bash
262
+ # Create config interactively
263
+ island-bridge init
264
+
265
+ # Check everything is working
266
+ island-bridge status
267
+
268
+ # Preview what would change
269
+ island-bridge pull --dry-run
270
+
271
+ # Diff preview (itemized changes)
272
+ island-bridge diff
273
+
274
+ # Sync with bandwidth limit
275
+ island-bridge pull --bwlimit 500
276
+
277
+ # Sync only specific folder
278
+ island-bridge pull --path app
279
+
280
+ # Auto-push on file changes
281
+ island-bridge watch
282
+
283
+ # Select specific folders interactively
284
+ island-bridge pull --select
285
+
286
+ # Use staging profile
287
+ island-bridge pull --env staging
288
+
289
+ # Quiet mode for CI/scripts
290
+ island-bridge push --quiet
291
+
292
+ # JSON output for AI/scripts
293
+ island-bridge pull --json
294
+
295
+ # View sync history
296
+ island-bridge history
297
+
298
+ # List and restore backups
299
+ island-bridge backup list
300
+ island-bridge backup restore 2026-04-07T14-30-00
301
+ ```
302
+
62
303
  ## Features
63
304
 
64
305
  - **Bidirectional sync** — `pull` downloads, `push` uploads
65
306
  - **Multi-folder** — sync multiple remote paths in one config
66
307
  - **rsync over SSH** — uses system SSH config for authentication
67
308
  - **Auto .gitignore** — respects `.gitignore` exclusion rules
309
+ - **Custom excludes** — additional exclude patterns in config
68
310
  - **Progress display** — real-time per-file transfer with color output
311
+ - **Dry-run mode** — preview changes without syncing
312
+ - **Diff preview** — see itemized file changes before sync
313
+ - **Watch mode** — auto-push on local file changes
314
+ - **Multi-environment** — switch profiles with `--env`
315
+ - **Interactive select** — choose which folders to sync
316
+ - **Hooks** — run commands before/after sync
317
+ - **Bandwidth limit** — control transfer speed
318
+ - **Sync history** — track past sync operations
319
+ - **Verbose/Quiet** — control output detail level
320
+ - **Config search** — finds config in parent directories
69
321
  - **Fault tolerant** — skips failed transfers, reports summary at end
70
322
  - **Zero dependencies** — pure Node.js, no npm dependencies
323
+ - **Auto backup** — files backed up before overwrite, with restore support
324
+ - **JSON output** — structured output for AI agents and scripts
325
+ - **Init command** — interactive or flag-based config setup
326
+ - **Status check** — diagnose SSH, rsync, and path issues
327
+ - **Path filter** — sync specific folders with `--path`
328
+ - **Error hints** — actionable suggestions on every error
71
329
 
72
330
  ## License
73
331
 
package/bin/cli.js CHANGED
@@ -1,65 +1,347 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { loadConfig } from '../lib/config.js';
4
- import { checkRsync, syncAll } from '../lib/sync.js';
5
- import { printSummary } from '../lib/summary.js';
3
+ import { parseArgs } from '../lib/args.js';
4
+ import { loadConfig, extractFolderName } from '../lib/config.js';
5
+ import { checkRsync, syncAll, diffPreview } from '../lib/sync.js';
6
+ import { Reporter } from '../lib/reporter.js';
7
+ import { startWatch } from '../lib/watch.js';
8
+ import { recordSync, showHistory } from '../lib/history.js';
9
+ import { runHook } from '../lib/hooks.js';
10
+ import { selectPaths } from '../lib/interactive.js';
11
+ import { runInit } from '../lib/init.js';
12
+ import { runStatus } from '../lib/status.js';
13
+ import { listBackups, restoreBackup, cleanBackups } from '../lib/backup.js';
14
+ import { getErrorHint } from '../lib/summary.js';
15
+ import { readFileSync } from 'node:fs';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { dirname, join } from 'node:path';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
6
21
 
7
22
  const USAGE = `
8
- island-bridge - Sync remote folders to local directory via rsync
23
+ island-bridge v${pkg.version} - Sync remote folders via rsync over SSH
9
24
 
10
25
  Usage:
11
- island-bridge pull Pull remote folders to local directory
12
- island-bridge push Push local folders to remote server
13
- island-bridge --help Show this help message
14
-
15
- Config:
16
- Place an island-bridge.json in the working directory:
17
- {
18
- "remote": {
19
- "host": "192.168.1.100",
20
- "user": "deploy",
21
- "paths": ["/var/www/app", "/etc/nginx/conf.d"]
22
- }
23
- }
26
+ island-bridge <command> [options]
27
+
28
+ Commands:
29
+ pull Pull remote folders to local directory
30
+ push Push local folders to remote server
31
+ watch Watch local folders and auto-push on changes
32
+ diff Preview changes without syncing
33
+ history Show sync history
34
+ init Create island-bridge.json config interactively
35
+ status Show config, SSH, rsync, and paths status
36
+ backup Manage sync backups (list, restore, clean)
37
+
38
+ Options:
39
+ -n, --dry-run Preview sync without making changes
40
+ -v, --verbose Show detailed output
41
+ -q, --quiet Suppress output (exit code only)
42
+ -c, --config <path> Use specific config file
43
+ --env <name> Use named profile from config
44
+ --bwlimit <KB/s> Limit transfer bandwidth
45
+ -s, --select Interactively select folders to sync
46
+ --path <name> Sync specific folder by name (repeatable)
47
+ --json Output in JSON format (for scripts/AI)
48
+ --no-backup Skip backup for this sync
49
+ -V, --version Show version
50
+ -h, --help Show this help message
51
+
52
+ Backup subcommands:
53
+ backup list List available backups
54
+ backup restore <timestamp> Restore files from a backup
55
+ backup clean --keep <N> Remove old backups, keep N most recent
24
56
  `.trim();
25
57
 
26
58
  async function main() {
27
- const command = process.argv[2];
59
+ let args;
60
+ try {
61
+ args = parseArgs();
62
+ } catch (err) {
63
+ console.error(`\x1b[31mError: ${err.message}\x1b[0m`);
64
+ process.exit(1);
65
+ }
66
+
67
+ const reporter = new Reporter(args.json ? 'json' : 'human');
28
68
 
29
- if (!command || command === '--help' || command === '-h') {
69
+ if (args.help || (!args.command && !args.version)) {
30
70
  console.log(USAGE);
31
71
  process.exit(0);
32
72
  }
33
73
 
34
- if (command !== 'pull' && command !== 'push') {
35
- console.error(`Unknown command: ${command}`);
36
- console.error('Use "island-bridge pull" or "island-bridge push"');
74
+ if (args.version) {
75
+ if (args.json) {
76
+ reporter.setCommand('version');
77
+ reporter.info(pkg.version);
78
+ reporter.flush();
79
+ } else {
80
+ console.log(`island-bridge v${pkg.version}`);
81
+ }
82
+ process.exit(0);
83
+ }
84
+
85
+ const validCommands = ['pull', 'push', 'watch', 'diff', 'history', 'init', 'status', 'backup'];
86
+ if (!validCommands.includes(args.command)) {
87
+ reporter.error(
88
+ `Unknown command: ${args.command}`,
89
+ `Valid commands: ${validCommands.join(', ')}`
90
+ );
91
+ if (args.json) reporter.flush();
92
+ process.exit(1);
93
+ }
94
+
95
+ reporter.setCommand(args.command);
96
+
97
+ // === Init ===
98
+ if (args.command === 'init') {
99
+ const ok = await runInit(args, reporter);
100
+ if (args.json) reporter.flush();
101
+ process.exit(ok ? 0 : 1);
102
+ }
103
+
104
+ // === History (doesn't need rsync) ===
105
+ if (args.command === 'history') {
106
+ let config;
107
+ try {
108
+ config = loadConfig({ configPath: args.config, env: args.env });
109
+ } catch {
110
+ // Show history from cwd if no config found
111
+ }
112
+ if (args.json) {
113
+ const { readFileSync: rfs, existsSync: efs } = await import('node:fs');
114
+ const { dirname: dn, join: jn } = await import('node:path');
115
+ const historyFile = config?._filePath
116
+ ? jn(dn(config._filePath), '.island-bridge-history.json')
117
+ : '.island-bridge-history.json';
118
+ let entries = [];
119
+ if (efs(historyFile)) {
120
+ try {
121
+ const parsed = JSON.parse(rfs(historyFile, 'utf-8'));
122
+ entries = Array.isArray(parsed) ? parsed : [];
123
+ } catch { /* ignore */ }
124
+ }
125
+ reporter.historyReport(entries);
126
+ reporter.flush();
127
+ } else {
128
+ showHistory(config?._filePath);
129
+ }
130
+ process.exit(0);
131
+ }
132
+
133
+ // === Backup ===
134
+ if (args.command === 'backup') {
135
+ let config;
136
+ try {
137
+ config = loadConfig({ configPath: args.config, env: args.env });
138
+ } catch {
139
+ config = { backup: { enabled: true, localDir: '.island-bridge-backups', remoteDir: '~/.island-bridge-backups', maxCount: 10 } };
140
+ }
141
+ const backupConfig = config.backup;
142
+
143
+ if (args.subcommand === 'list') {
144
+ const backups = listBackups(backupConfig.localDir);
145
+ if (args.json) {
146
+ reporter.historyReport(backups.map(b => ({ timestamp: b.name, date: b.date.toISOString() })));
147
+ reporter.flush();
148
+ } else {
149
+ if (backups.length === 0) {
150
+ console.log('No backups found.');
151
+ } else {
152
+ console.log('\n--- Backups ---\n');
153
+ for (const b of backups) {
154
+ console.log(` ${b.name} (${b.date.toLocaleString()})`);
155
+ }
156
+ console.log(`\n${backups.length} backup(s) found.`);
157
+ }
158
+ }
159
+ process.exit(0);
160
+ }
161
+
162
+ if (args.subcommand === 'restore') {
163
+ if (!args.backupTimestamp) {
164
+ reporter.error('backup restore requires a timestamp', 'Run "island-bridge backup list" to see available backups');
165
+ if (args.json) reporter.flush();
166
+ process.exit(1);
167
+ }
168
+ try {
169
+ const results = restoreBackup(backupConfig.localDir, args.backupTimestamp);
170
+ if (args.json) {
171
+ for (const r of results) {
172
+ reporter.syncEnd(r.folder, r);
173
+ }
174
+ reporter.flush();
175
+ } else {
176
+ for (const r of results) {
177
+ if (r.success) {
178
+ console.log(` \x1b[32m\u2713\x1b[0m ${r.folder} restored`);
179
+ } else {
180
+ console.log(` \x1b[31m\u2717\x1b[0m ${r.folder} \u2014 ${r.error}`);
181
+ }
182
+ }
183
+ }
184
+ } catch (err) {
185
+ reporter.error(err.message, 'Run "island-bridge backup list" to see available backups');
186
+ if (args.json) reporter.flush();
187
+ process.exit(1);
188
+ }
189
+ process.exit(0);
190
+ }
191
+
192
+ if (args.subcommand === 'clean') {
193
+ const keep = args.keep || backupConfig.maxCount || 10;
194
+ const removed = cleanBackups(backupConfig.localDir, keep);
195
+ if (args.json) {
196
+ reporter.info(`Removed ${removed.length} backup(s), keeping ${keep}`);
197
+ reporter.flush();
198
+ } else {
199
+ if (removed.length === 0) {
200
+ console.log(`No backups to remove (keeping ${keep}).`);
201
+ } else {
202
+ for (const name of removed) {
203
+ console.log(` Removed: ${name}`);
204
+ }
205
+ console.log(`\nRemoved ${removed.length} backup(s), keeping ${keep}.`);
206
+ }
207
+ }
208
+ process.exit(0);
209
+ }
210
+
211
+ reporter.error(
212
+ 'Unknown backup subcommand',
213
+ 'Usage: island-bridge backup <list|restore|clean>'
214
+ );
215
+ if (args.json) reporter.flush();
37
216
  process.exit(1);
38
217
  }
39
218
 
40
- // Pre-flight: check rsync
219
+ // === Pre-flight: check rsync ===
41
220
  const rsyncOk = await checkRsync();
42
221
  if (!rsyncOk) {
43
- console.error('\x1b[31mError: rsync is required but not found in PATH. Install rsync and try again.\x1b[0m');
222
+ reporter.error(
223
+ 'rsync is required but not found in PATH',
224
+ getErrorHint('rsync-not-found')
225
+ );
226
+ if (args.json) reporter.flush();
44
227
  process.exit(1);
45
228
  }
46
229
 
47
- // Load config
230
+ // === Load config ===
48
231
  let config;
49
232
  try {
50
- config = loadConfig();
233
+ config = loadConfig({ configPath: args.config, env: args.env });
51
234
  } catch (err) {
52
- console.error(`\x1b[31mError: ${err.message}\x1b[0m`);
235
+ const hint = err.message.includes('Cannot find')
236
+ ? getErrorHint('config-not-found')
237
+ : null;
238
+ reporter.error(err.message, hint);
239
+ if (args.json) reporter.flush();
53
240
  process.exit(1);
54
241
  }
55
242
 
56
- // Execute sync
57
- const results = await syncAll(config, command);
243
+ if (args.env) {
244
+ reporter.info(`Using profile: ${args.env}`);
245
+ }
246
+ if (config._filePath) {
247
+ reporter.info(`Config: ${config._filePath}`);
248
+ }
249
+
250
+ // === Status ===
251
+ if (args.command === 'status') {
252
+ await runStatus(config, reporter);
253
+ if (args.json) reporter.flush();
254
+ process.exit(0);
255
+ }
256
+
257
+ // === --path filter ===
258
+ if (args.path.length > 0) {
259
+ const allNames = config.remote.paths.map(p => extractFolderName(p));
260
+ const invalid = args.path.filter(name => !allNames.includes(name));
261
+ if (invalid.length > 0) {
262
+ reporter.error(
263
+ `Unknown folder name(s): ${invalid.join(', ')}`,
264
+ `Available folders: ${allNames.join(', ')}`
265
+ );
266
+ if (args.json) reporter.flush();
267
+ process.exit(1);
268
+ }
269
+ config.remote.paths = config.remote.paths.filter(p =>
270
+ args.path.includes(extractFolderName(p))
271
+ );
272
+ }
273
+
274
+ // === Interactive selection ===
275
+ if (args.select && !args.json && ['pull', 'push', 'diff'].includes(args.command)) {
276
+ config.remote.paths = await selectPaths(config.remote.paths, reporter);
277
+ }
278
+
279
+ // === Watch mode ===
280
+ if (args.command === 'watch') {
281
+ startWatch(config, {
282
+ dryRun: args.dryRun,
283
+ verbose: args.verbose,
284
+ quiet: args.quiet,
285
+ bwlimit: args.bwlimit,
286
+ noBackup: args.noBackup,
287
+ }, reporter);
288
+ return;
289
+ }
290
+
291
+ // === Diff preview ===
292
+ if (args.command === 'diff') {
293
+ const diffs = await diffPreview(config, 'pull', {
294
+ bwlimit: args.bwlimit,
295
+ exclude: config.exclude,
296
+ });
297
+ reporter.diffReport(diffs);
298
+ if (args.json) reporter.flush();
299
+ process.exit(0);
300
+ }
301
+
302
+ // === Pull or Push ===
303
+ const options = {
304
+ dryRun: args.dryRun,
305
+ verbose: args.verbose,
306
+ quiet: args.quiet,
307
+ bwlimit: args.bwlimit,
308
+ noBackup: args.noBackup,
309
+ };
310
+
311
+ // Disable hooks from configs found via upward search
312
+ if (!config._explicitConfig && config._filePath !== join(process.cwd(), 'island-bridge.json')) {
313
+ if (config.hooks.beforeSync || config.hooks.afterSync) {
314
+ reporter.warn(`hooks ignored from inherited config ${config._filePath}`);
315
+ reporter.warn(`Use --config ${config._filePath} to explicitly enable hooks.`);
316
+ config.hooks = {};
317
+ }
318
+ }
319
+
320
+ // Run beforeSync hook
321
+ if (config.hooks.beforeSync) {
322
+ runHook('beforeSync', config.hooks.beforeSync, args.quiet, reporter);
323
+ }
324
+
325
+ const results = await syncAll(config, args.command, options, reporter);
326
+
327
+ // Run afterSync hook
328
+ if (config.hooks.afterSync) {
329
+ runHook('afterSync', config.hooks.afterSync, args.quiet, reporter);
330
+ }
331
+
332
+ // Record history
333
+ recordSync(config._filePath, args.command, results);
58
334
 
59
335
  // Print summary
60
- printSummary(results);
336
+ if (!args.quiet || args.json) {
337
+ reporter.summary(results);
338
+ if (!args.json && args.dryRun) {
339
+ reporter.info('(dry-run mode \u2014 no changes were made)');
340
+ }
341
+ }
342
+
343
+ if (args.json) reporter.flush();
61
344
 
62
- // Exit with non-zero code if any transfers failed
63
345
  const hasFailed = results.some(r => !r.success);
64
346
  process.exit(hasFailed ? 1 : 0);
65
347
  }