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 +21 -0
- package/README.md +259 -1
- package/bin/cli.js +313 -31
- package/lib/args.js +124 -0
- package/lib/backup.js +124 -0
- package/lib/config.js +159 -11
- package/lib/history.js +94 -0
- package/lib/hooks.js +33 -0
- package/lib/init.js +110 -0
- package/lib/interactive.js +52 -0
- package/lib/progress.js +31 -17
- package/lib/reporter.js +187 -0
- package/lib/status.js +127 -0
- package/lib/summary.js +27 -0
- package/lib/sync.js +155 -15
- package/lib/watch.js +78 -0
- package/package.json +2 -2
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 {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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
|
|
23
|
+
island-bridge v${pkg.version} - Sync remote folders via rsync over SSH
|
|
9
24
|
|
|
10
25
|
Usage:
|
|
11
|
-
island-bridge
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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 (
|
|
69
|
+
if (args.help || (!args.command && !args.version)) {
|
|
30
70
|
console.log(USAGE);
|
|
31
71
|
process.exit(0);
|
|
32
72
|
}
|
|
33
73
|
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
}
|