island-bridge 1.0.0 → 1.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.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/bin/cli.js +156 -23
- package/lib/args.js +75 -0
- package/lib/config.js +118 -11
- package/lib/history.js +94 -0
- package/lib/hooks.js +26 -0
- package/lib/interactive.js +46 -0
- package/lib/progress.js +4 -1
- package/lib/sync.js +119 -11
- package/lib/watch.js +64 -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
|
@@ -59,13 +59,152 @@ This creates local subdirectories matching the remote folder names:
|
|
|
59
59
|
island-bridge push
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
## Commands
|
|
63
|
+
|
|
64
|
+
| Command | Description |
|
|
65
|
+
|---------|-------------|
|
|
66
|
+
| `pull` | Pull remote folders to local directory |
|
|
67
|
+
| `push` | Push local folders to remote server |
|
|
68
|
+
| `watch` | Watch local folders and auto-push on changes |
|
|
69
|
+
| `diff` | Preview changes without syncing |
|
|
70
|
+
| `history` | Show sync history |
|
|
71
|
+
|
|
72
|
+
## Options
|
|
73
|
+
|
|
74
|
+
| Option | Description |
|
|
75
|
+
|--------|-------------|
|
|
76
|
+
| `-n, --dry-run` | Preview sync without making changes |
|
|
77
|
+
| `-v, --verbose` | Show detailed output |
|
|
78
|
+
| `-q, --quiet` | Suppress output (exit code only) |
|
|
79
|
+
| `-c, --config <path>` | Use specific config file |
|
|
80
|
+
| `--env <name>` | Use named profile from config |
|
|
81
|
+
| `--bwlimit <KB/s>` | Limit transfer bandwidth |
|
|
82
|
+
| `-s, --select` | Interactively select folders to sync |
|
|
83
|
+
| `-V, --version` | Show version |
|
|
84
|
+
| `-h, --help` | Show help |
|
|
85
|
+
|
|
86
|
+
## Advanced Config
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"remote": {
|
|
91
|
+
"host": "192.168.1.100",
|
|
92
|
+
"user": "deploy",
|
|
93
|
+
"paths": ["/var/www/app", "/etc/nginx/conf.d"]
|
|
94
|
+
},
|
|
95
|
+
"exclude": ["node_modules", ".DS_Store", "*.log"],
|
|
96
|
+
"bwlimit": 1000,
|
|
97
|
+
"hooks": {
|
|
98
|
+
"beforeSync": "echo 'Starting sync...'",
|
|
99
|
+
"afterSync": "pm2 restart app"
|
|
100
|
+
},
|
|
101
|
+
"profiles": {
|
|
102
|
+
"staging": {
|
|
103
|
+
"remote": {
|
|
104
|
+
"host": "staging.example.com",
|
|
105
|
+
"user": "deploy",
|
|
106
|
+
"paths": ["/var/www/app"]
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"production": {
|
|
110
|
+
"remote": {
|
|
111
|
+
"host": "prod.example.com",
|
|
112
|
+
"user": "admin",
|
|
113
|
+
"paths": ["/var/www/app"]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Exclude Rules
|
|
121
|
+
|
|
122
|
+
Custom exclude patterns are applied in addition to `.gitignore` rules:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"exclude": ["node_modules", "*.log", ".env"]
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Hooks
|
|
131
|
+
|
|
132
|
+
Run shell commands before and after sync:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"hooks": {
|
|
137
|
+
"beforeSync": "npm run build",
|
|
138
|
+
"afterSync": "ssh deploy@server 'pm2 restart app'"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Multi-Environment Profiles
|
|
144
|
+
|
|
145
|
+
Switch between environments using `--env`:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
island-bridge pull --env staging
|
|
149
|
+
island-bridge push --env production
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Profile settings are merged over the base config.
|
|
153
|
+
|
|
154
|
+
### Config File Search
|
|
155
|
+
|
|
156
|
+
The config file is searched starting from the current directory upward (like `.gitignore`). Use `--config` to specify an explicit path:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
island-bridge pull --config /path/to/my-config.json
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Examples
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
# Preview what would change
|
|
166
|
+
island-bridge pull --dry-run
|
|
167
|
+
|
|
168
|
+
# Diff preview (itemized changes)
|
|
169
|
+
island-bridge diff
|
|
170
|
+
|
|
171
|
+
# Sync with bandwidth limit
|
|
172
|
+
island-bridge pull --bwlimit 500
|
|
173
|
+
|
|
174
|
+
# Auto-push on file changes
|
|
175
|
+
island-bridge watch
|
|
176
|
+
|
|
177
|
+
# Select specific folders interactively
|
|
178
|
+
island-bridge pull --select
|
|
179
|
+
|
|
180
|
+
# Use staging profile
|
|
181
|
+
island-bridge pull --env staging
|
|
182
|
+
|
|
183
|
+
# Quiet mode for CI/scripts
|
|
184
|
+
island-bridge push --quiet
|
|
185
|
+
|
|
186
|
+
# View sync history
|
|
187
|
+
island-bridge history
|
|
188
|
+
```
|
|
189
|
+
|
|
62
190
|
## Features
|
|
63
191
|
|
|
64
192
|
- **Bidirectional sync** — `pull` downloads, `push` uploads
|
|
65
193
|
- **Multi-folder** — sync multiple remote paths in one config
|
|
66
194
|
- **rsync over SSH** — uses system SSH config for authentication
|
|
67
195
|
- **Auto .gitignore** — respects `.gitignore` exclusion rules
|
|
196
|
+
- **Custom excludes** — additional exclude patterns in config
|
|
68
197
|
- **Progress display** — real-time per-file transfer with color output
|
|
198
|
+
- **Dry-run mode** — preview changes without syncing
|
|
199
|
+
- **Diff preview** — see itemized file changes before sync
|
|
200
|
+
- **Watch mode** — auto-push on local file changes
|
|
201
|
+
- **Multi-environment** — switch profiles with `--env`
|
|
202
|
+
- **Interactive select** — choose which folders to sync
|
|
203
|
+
- **Hooks** — run commands before/after sync
|
|
204
|
+
- **Bandwidth limit** — control transfer speed
|
|
205
|
+
- **Sync history** — track past sync operations
|
|
206
|
+
- **Verbose/Quiet** — control output detail level
|
|
207
|
+
- **Config search** — finds config in parent directories
|
|
69
208
|
- **Fault tolerant** — skips failed transfers, reports summary at end
|
|
70
209
|
- **Zero dependencies** — pure Node.js, no npm dependencies
|
|
71
210
|
|
package/bin/cli.js
CHANGED
|
@@ -1,42 +1,92 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { parseArgs } from '../lib/args.js';
|
|
3
4
|
import { loadConfig } from '../lib/config.js';
|
|
4
|
-
import { checkRsync, syncAll } from '../lib/sync.js';
|
|
5
|
+
import { checkRsync, syncAll, diffPreview } from '../lib/sync.js';
|
|
5
6
|
import { printSummary } from '../lib/summary.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 { readFileSync } from 'node:fs';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
6
17
|
|
|
7
18
|
const USAGE = `
|
|
8
|
-
island-bridge - Sync remote folders
|
|
19
|
+
island-bridge v${pkg.version} - Sync remote folders via rsync over SSH
|
|
9
20
|
|
|
10
21
|
Usage:
|
|
11
|
-
island-bridge
|
|
12
|
-
|
|
13
|
-
|
|
22
|
+
island-bridge <command> [options]
|
|
23
|
+
|
|
24
|
+
Commands:
|
|
25
|
+
pull Pull remote folders to local directory
|
|
26
|
+
push Push local folders to remote server
|
|
27
|
+
watch Watch local folders and auto-push on changes
|
|
28
|
+
diff Preview changes without syncing
|
|
29
|
+
history Show sync history
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
-n, --dry-run Preview sync without making changes
|
|
33
|
+
-v, --verbose Show detailed output
|
|
34
|
+
-q, --quiet Suppress output (exit code only)
|
|
35
|
+
-c, --config <path> Use specific config file
|
|
36
|
+
--env <name> Use named profile from config
|
|
37
|
+
--bwlimit <KB/s> Limit transfer bandwidth
|
|
38
|
+
-s, --select Interactively select folders to sync
|
|
39
|
+
-V, --version Show version
|
|
40
|
+
-h, --help Show this help message
|
|
14
41
|
|
|
15
|
-
Config:
|
|
16
|
-
Place an island-bridge.json in the working directory:
|
|
42
|
+
Config (island-bridge.json):
|
|
17
43
|
{
|
|
18
|
-
"remote": {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
44
|
+
"remote": { "host": "...", "user": "...", "paths": [...] },
|
|
45
|
+
"exclude": ["node_modules", "*.log"],
|
|
46
|
+
"bwlimit": 1000,
|
|
47
|
+
"hooks": { "beforeSync": "...", "afterSync": "..." },
|
|
48
|
+
"profiles": { "staging": { "remote": { ... } } }
|
|
23
49
|
}
|
|
24
50
|
`.trim();
|
|
25
51
|
|
|
26
52
|
async function main() {
|
|
27
|
-
|
|
53
|
+
let args;
|
|
54
|
+
try {
|
|
55
|
+
args = parseArgs();
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error(`\x1b[31mError: ${err.message}\x1b[0m`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
28
60
|
|
|
29
|
-
if (
|
|
61
|
+
if (args.help || (!args.command && !args.version)) {
|
|
30
62
|
console.log(USAGE);
|
|
31
63
|
process.exit(0);
|
|
32
64
|
}
|
|
33
65
|
|
|
34
|
-
if (
|
|
35
|
-
console.
|
|
36
|
-
|
|
66
|
+
if (args.version) {
|
|
67
|
+
console.log(`island-bridge v${pkg.version}`);
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const validCommands = ['pull', 'push', 'watch', 'diff', 'history'];
|
|
72
|
+
if (!validCommands.includes(args.command)) {
|
|
73
|
+
console.error(`Unknown command: ${args.command}`);
|
|
74
|
+
console.error(`Valid commands: ${validCommands.join(', ')}`);
|
|
37
75
|
process.exit(1);
|
|
38
76
|
}
|
|
39
77
|
|
|
78
|
+
// History doesn't need rsync
|
|
79
|
+
if (args.command === 'history') {
|
|
80
|
+
let config;
|
|
81
|
+
try {
|
|
82
|
+
config = loadConfig({ configPath: args.config, env: args.env });
|
|
83
|
+
} catch {
|
|
84
|
+
// Show history from cwd if no config found
|
|
85
|
+
}
|
|
86
|
+
showHistory(config?._filePath);
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
|
|
40
90
|
// Pre-flight: check rsync
|
|
41
91
|
const rsyncOk = await checkRsync();
|
|
42
92
|
if (!rsyncOk) {
|
|
@@ -47,19 +97,102 @@ async function main() {
|
|
|
47
97
|
// Load config
|
|
48
98
|
let config;
|
|
49
99
|
try {
|
|
50
|
-
config = loadConfig();
|
|
100
|
+
config = loadConfig({ configPath: args.config, env: args.env });
|
|
51
101
|
} catch (err) {
|
|
52
102
|
console.error(`\x1b[31mError: ${err.message}\x1b[0m`);
|
|
53
103
|
process.exit(1);
|
|
54
104
|
}
|
|
55
105
|
|
|
56
|
-
|
|
57
|
-
|
|
106
|
+
if (!args.quiet && args.env) {
|
|
107
|
+
console.log(`\x1b[90mUsing profile: ${args.env}\x1b[0m`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!args.quiet && config._filePath) {
|
|
111
|
+
console.log(`\x1b[90mConfig: ${config._filePath}\x1b[0m`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Interactive selection
|
|
115
|
+
if (args.select && ['pull', 'push', 'diff'].includes(args.command)) {
|
|
116
|
+
config.remote.paths = await selectPaths(config.remote.paths);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Watch mode
|
|
120
|
+
if (args.command === 'watch') {
|
|
121
|
+
startWatch(config, {
|
|
122
|
+
dryRun: args.dryRun,
|
|
123
|
+
verbose: args.verbose,
|
|
124
|
+
quiet: args.quiet,
|
|
125
|
+
bwlimit: args.bwlimit,
|
|
126
|
+
});
|
|
127
|
+
return; // watch runs indefinitely
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Diff preview
|
|
131
|
+
if (args.command === 'diff') {
|
|
132
|
+
const diffs = await diffPreview(config, 'pull', {
|
|
133
|
+
bwlimit: args.bwlimit,
|
|
134
|
+
exclude: config.exclude,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (diffs.length === 0) {
|
|
138
|
+
console.log('\nNo changes detected.');
|
|
139
|
+
} else {
|
|
140
|
+
for (const d of diffs) {
|
|
141
|
+
console.log(`\n\x1b[1m${d.folderName}\x1b[0m (${d.remotePath}):`);
|
|
142
|
+
for (const line of d.changes) {
|
|
143
|
+
if (line.startsWith('*deleting')) {
|
|
144
|
+
console.log(` \x1b[31m- ${line}\x1b[0m`);
|
|
145
|
+
} else if (line.startsWith('>') || line.startsWith('<')) {
|
|
146
|
+
console.log(` \x1b[32m+ ${line}\x1b[0m`);
|
|
147
|
+
} else {
|
|
148
|
+
console.log(` \x1b[33m~ ${line}\x1b[0m`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
process.exit(0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Pull or Push
|
|
157
|
+
const options = {
|
|
158
|
+
dryRun: args.dryRun,
|
|
159
|
+
verbose: args.verbose,
|
|
160
|
+
quiet: args.quiet,
|
|
161
|
+
bwlimit: args.bwlimit,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Disable hooks from configs found via upward search (planted-config protection)
|
|
165
|
+
if (!config._explicitConfig && config._filePath !== join(process.cwd(), 'island-bridge.json')) {
|
|
166
|
+
if (config.hooks.beforeSync || config.hooks.afterSync) {
|
|
167
|
+
console.warn(`\x1b[33mWarning: hooks ignored from inherited config ${config._filePath}\x1b[0m`);
|
|
168
|
+
console.warn(`\x1b[33mUse --config ${config._filePath} to explicitly enable hooks.\x1b[0m`);
|
|
169
|
+
config.hooks = {};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Run beforeSync hook
|
|
174
|
+
if (config.hooks.beforeSync) {
|
|
175
|
+
runHook('beforeSync', config.hooks.beforeSync, args.quiet);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const results = await syncAll(config, args.command, options);
|
|
58
179
|
|
|
59
|
-
//
|
|
60
|
-
|
|
180
|
+
// Run afterSync hook
|
|
181
|
+
if (config.hooks.afterSync) {
|
|
182
|
+
runHook('afterSync', config.hooks.afterSync, args.quiet);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Record history
|
|
186
|
+
recordSync(config._filePath, args.command, results);
|
|
187
|
+
|
|
188
|
+
// Print summary (unless quiet)
|
|
189
|
+
if (!args.quiet) {
|
|
190
|
+
printSummary(results);
|
|
191
|
+
if (args.dryRun) {
|
|
192
|
+
console.log('\n\x1b[90m(dry-run mode \u2014 no changes were made)\x1b[0m');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
61
195
|
|
|
62
|
-
// Exit with non-zero code if any transfers failed
|
|
63
196
|
const hasFailed = results.some(r => !r.success);
|
|
64
197
|
process.exit(hasFailed ? 1 : 0);
|
|
65
198
|
}
|
package/lib/args.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight CLI argument parser for island-bridge.
|
|
3
|
+
* Zero dependencies.
|
|
4
|
+
*/
|
|
5
|
+
export function parseArgs(argv = process.argv.slice(2)) {
|
|
6
|
+
const args = {
|
|
7
|
+
command: null,
|
|
8
|
+
dryRun: false,
|
|
9
|
+
verbose: false,
|
|
10
|
+
quiet: false,
|
|
11
|
+
config: null,
|
|
12
|
+
env: null,
|
|
13
|
+
bwlimit: null,
|
|
14
|
+
select: false,
|
|
15
|
+
help: false,
|
|
16
|
+
version: false,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const arg = argv[i];
|
|
21
|
+
switch (arg) {
|
|
22
|
+
case '--dry-run':
|
|
23
|
+
case '-n':
|
|
24
|
+
args.dryRun = true;
|
|
25
|
+
break;
|
|
26
|
+
case '--verbose':
|
|
27
|
+
case '-v':
|
|
28
|
+
args.verbose = true;
|
|
29
|
+
break;
|
|
30
|
+
case '--quiet':
|
|
31
|
+
case '-q':
|
|
32
|
+
args.quiet = true;
|
|
33
|
+
break;
|
|
34
|
+
case '--config':
|
|
35
|
+
case '-c':
|
|
36
|
+
args.config = argv[++i];
|
|
37
|
+
if (!args.config || args.config.startsWith('-')) throw new Error('--config requires a file path');
|
|
38
|
+
break;
|
|
39
|
+
case '--env':
|
|
40
|
+
args.env = argv[++i];
|
|
41
|
+
if (!args.env || args.env.startsWith('-')) throw new Error('--env requires a profile name');
|
|
42
|
+
break;
|
|
43
|
+
case '--bwlimit':
|
|
44
|
+
args.bwlimit = argv[++i];
|
|
45
|
+
if (!args.bwlimit || isNaN(Number(args.bwlimit))) {
|
|
46
|
+
throw new Error('--bwlimit requires a numeric value (KB/s)');
|
|
47
|
+
}
|
|
48
|
+
args.bwlimit = Number(args.bwlimit);
|
|
49
|
+
break;
|
|
50
|
+
case '--select':
|
|
51
|
+
case '-s':
|
|
52
|
+
args.select = true;
|
|
53
|
+
break;
|
|
54
|
+
case '--help':
|
|
55
|
+
case '-h':
|
|
56
|
+
args.help = true;
|
|
57
|
+
break;
|
|
58
|
+
case '--version':
|
|
59
|
+
case '-V':
|
|
60
|
+
args.version = true;
|
|
61
|
+
break;
|
|
62
|
+
default:
|
|
63
|
+
if (!arg.startsWith('-') && !args.command) {
|
|
64
|
+
args.command = arg;
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (args.verbose && args.quiet) {
|
|
71
|
+
throw new Error('Cannot use --verbose and --quiet together');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return args;
|
|
75
|
+
}
|
package/lib/config.js
CHANGED
|
@@ -1,27 +1,87 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { basename } from 'node:path';
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { basename, resolve, dirname, join } from 'node:path';
|
|
3
3
|
|
|
4
4
|
const CONFIG_FILE = 'island-bridge.json';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* Search for config file starting from startDir, walking up to root.
|
|
8
8
|
*/
|
|
9
|
-
export function
|
|
9
|
+
export function findConfigFile(startDir = process.cwd()) {
|
|
10
|
+
let dir = resolve(startDir);
|
|
11
|
+
|
|
12
|
+
while (true) {
|
|
13
|
+
const candidate = join(dir, CONFIG_FILE);
|
|
14
|
+
if (existsSync(candidate)) return candidate;
|
|
15
|
+
const parent = dirname(dir);
|
|
16
|
+
if (parent === dir) break;
|
|
17
|
+
dir = parent;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load and validate config.
|
|
25
|
+
* @param {object} [options]
|
|
26
|
+
* @param {string} [options.configPath] - Explicit config file path (--config)
|
|
27
|
+
* @param {string} [options.env] - Profile name to merge (--env)
|
|
28
|
+
*/
|
|
29
|
+
export function loadConfig({ configPath, env } = {}) {
|
|
30
|
+
let filePath;
|
|
31
|
+
|
|
32
|
+
if (configPath) {
|
|
33
|
+
filePath = resolve(configPath);
|
|
34
|
+
if (!existsSync(filePath)) {
|
|
35
|
+
throw new Error(`Config file not found: ${configPath}`);
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
filePath = findConfigFile();
|
|
39
|
+
if (!filePath) {
|
|
40
|
+
throw new Error(`Cannot find ${CONFIG_FILE} in current or parent directories`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
10
44
|
let raw;
|
|
11
45
|
try {
|
|
12
|
-
raw = readFileSync(
|
|
46
|
+
raw = readFileSync(filePath, 'utf-8');
|
|
13
47
|
} catch {
|
|
14
|
-
throw new Error(`Failed to read ${
|
|
48
|
+
throw new Error(`Failed to read config: ${filePath}`);
|
|
15
49
|
}
|
|
16
50
|
|
|
17
51
|
let config;
|
|
18
52
|
try {
|
|
19
53
|
config = JSON.parse(raw);
|
|
20
54
|
} catch {
|
|
21
|
-
throw new Error(`Failed to parse ${
|
|
55
|
+
throw new Error(`Failed to parse ${filePath}: invalid JSON`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Merge profile if specified
|
|
59
|
+
if (env) {
|
|
60
|
+
if (env === '__proto__' || env === 'constructor' || env === 'prototype') {
|
|
61
|
+
throw new Error(`Config error: profile name '${env}' is not allowed`);
|
|
62
|
+
}
|
|
63
|
+
if (!config.profiles || !config.profiles[env]) {
|
|
64
|
+
const available = config.profiles ? Object.keys(config.profiles).join(', ') : 'none';
|
|
65
|
+
throw new Error(`Profile '${env}' not found. Available: ${available}`);
|
|
66
|
+
}
|
|
67
|
+
const profile = config.profiles[env];
|
|
68
|
+
config = {
|
|
69
|
+
...config,
|
|
70
|
+
...profile,
|
|
71
|
+
remote: { ...config.remote, ...profile.remote },
|
|
72
|
+
hooks: { ...config.hooks, ...profile.hooks },
|
|
73
|
+
};
|
|
22
74
|
}
|
|
23
75
|
|
|
24
76
|
validate(config);
|
|
77
|
+
|
|
78
|
+
// Normalize optional fields
|
|
79
|
+
config.exclude = config.exclude || [];
|
|
80
|
+
config.hooks = config.hooks || {};
|
|
81
|
+
config.bwlimit = config.bwlimit || null;
|
|
82
|
+
config._filePath = filePath;
|
|
83
|
+
config._explicitConfig = !!configPath;
|
|
84
|
+
|
|
25
85
|
return config;
|
|
26
86
|
}
|
|
27
87
|
|
|
@@ -38,15 +98,15 @@ function validate(config) {
|
|
|
38
98
|
if (!host || typeof host !== 'string' || host.trim() === '') {
|
|
39
99
|
throw new Error("Config error: 'host' must be a non-empty string");
|
|
40
100
|
}
|
|
41
|
-
if (
|
|
42
|
-
throw new Error("Config error: 'host'
|
|
101
|
+
if (!/^[a-zA-Z0-9._:-]+$/.test(host)) {
|
|
102
|
+
throw new Error("Config error: 'host' contains invalid characters. Use hostname, IPv4, or IPv6 only");
|
|
43
103
|
}
|
|
44
104
|
|
|
45
105
|
if (!user || typeof user !== 'string' || user.trim() === '') {
|
|
46
106
|
throw new Error("Config error: 'user' must be a non-empty string");
|
|
47
107
|
}
|
|
48
|
-
if (
|
|
49
|
-
throw new Error("Config error: 'user'
|
|
108
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(user)) {
|
|
109
|
+
throw new Error("Config error: 'user' contains invalid characters");
|
|
50
110
|
}
|
|
51
111
|
|
|
52
112
|
if (!Array.isArray(paths) || paths.length === 0) {
|
|
@@ -61,12 +121,59 @@ function validate(config) {
|
|
|
61
121
|
if (p.startsWith('-')) {
|
|
62
122
|
throw new Error(`Config error: remote path '${p}' must not start with '-'`);
|
|
63
123
|
}
|
|
124
|
+
if (/[`$;|&><(){}]/.test(p)) {
|
|
125
|
+
throw new Error(`Config error: remote path '${p}' contains disallowed characters`);
|
|
126
|
+
}
|
|
64
127
|
const name = extractFolderName(p);
|
|
65
128
|
if (seen.has(name)) {
|
|
66
129
|
throw new Error(`Config error: folder name collision — multiple remote paths resolve to '${name}'`);
|
|
67
130
|
}
|
|
68
131
|
seen.add(name);
|
|
69
132
|
}
|
|
133
|
+
|
|
134
|
+
// Validate exclude
|
|
135
|
+
if (config.exclude !== undefined) {
|
|
136
|
+
if (!Array.isArray(config.exclude)) {
|
|
137
|
+
throw new Error("Config error: 'exclude' must be an array of strings");
|
|
138
|
+
}
|
|
139
|
+
for (const e of config.exclude) {
|
|
140
|
+
if (typeof e !== 'string') {
|
|
141
|
+
throw new Error("Config error: each exclude pattern must be a string");
|
|
142
|
+
}
|
|
143
|
+
if (e.startsWith('-')) {
|
|
144
|
+
throw new Error(`Config error: exclude pattern '${e}' must not start with '-'`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Validate hooks
|
|
150
|
+
if (config.hooks !== undefined) {
|
|
151
|
+
if (typeof config.hooks !== 'object' || Array.isArray(config.hooks)) {
|
|
152
|
+
throw new Error("Config error: 'hooks' must be an object");
|
|
153
|
+
}
|
|
154
|
+
for (const key of Object.keys(config.hooks)) {
|
|
155
|
+
if (key !== 'beforeSync' && key !== 'afterSync') {
|
|
156
|
+
throw new Error(`Config error: unknown hook '${key}'. Supported: beforeSync, afterSync`);
|
|
157
|
+
}
|
|
158
|
+
if (typeof config.hooks[key] !== 'string') {
|
|
159
|
+
throw new Error(`Config error: hook '${key}' must be a string`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Validate bwlimit
|
|
165
|
+
if (config.bwlimit !== undefined && config.bwlimit !== null) {
|
|
166
|
+
if (typeof config.bwlimit !== 'number' || config.bwlimit <= 0) {
|
|
167
|
+
throw new Error("Config error: 'bwlimit' must be a positive number (KB/s)");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Validate profiles
|
|
172
|
+
if (config.profiles !== undefined) {
|
|
173
|
+
if (typeof config.profiles !== 'object' || Array.isArray(config.profiles)) {
|
|
174
|
+
throw new Error("Config error: 'profiles' must be an object");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
70
177
|
}
|
|
71
178
|
|
|
72
179
|
/**
|
package/lib/history.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, lstatSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const HISTORY_FILE = '.island-bridge-history.json';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Record a sync operation to history.
|
|
8
|
+
*/
|
|
9
|
+
export function recordSync(configPath, direction, results) {
|
|
10
|
+
const historyPath = configPath ? join(dirname(configPath), HISTORY_FILE) : HISTORY_FILE;
|
|
11
|
+
|
|
12
|
+
let history = [];
|
|
13
|
+
if (existsSync(historyPath)) {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(readFileSync(historyPath, 'utf-8'));
|
|
16
|
+
history = Array.isArray(parsed) ? parsed : [];
|
|
17
|
+
} catch {
|
|
18
|
+
history = [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const entry = {
|
|
23
|
+
timestamp: new Date().toISOString(),
|
|
24
|
+
direction,
|
|
25
|
+
folders: results.map(r => ({
|
|
26
|
+
name: r.folderName,
|
|
27
|
+
path: r.remotePath,
|
|
28
|
+
success: r.success,
|
|
29
|
+
error: r.error || null,
|
|
30
|
+
})),
|
|
31
|
+
success: results.every(r => r.success),
|
|
32
|
+
total: results.length,
|
|
33
|
+
failed: results.filter(r => !r.success).length,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
history.push(entry);
|
|
37
|
+
|
|
38
|
+
// Keep last 100 entries
|
|
39
|
+
if (history.length > 100) {
|
|
40
|
+
history = history.slice(-100);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Refuse to write through symlinks
|
|
44
|
+
if (existsSync(historyPath) && lstatSync(historyPath).isSymbolicLink()) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
writeFileSync(historyPath, JSON.stringify(history, null, 2) + '\n');
|
|
50
|
+
} catch {
|
|
51
|
+
// Silently ignore write errors for history
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Display sync history.
|
|
57
|
+
*/
|
|
58
|
+
export function showHistory(configPath) {
|
|
59
|
+
const historyPath = configPath ? join(dirname(configPath), HISTORY_FILE) : HISTORY_FILE;
|
|
60
|
+
|
|
61
|
+
if (!existsSync(historyPath)) {
|
|
62
|
+
console.log('No sync history found.');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let history;
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(readFileSync(historyPath, 'utf-8'));
|
|
69
|
+
history = Array.isArray(parsed) ? parsed : [];
|
|
70
|
+
} catch {
|
|
71
|
+
console.log('Failed to read history file.');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (history.length === 0) {
|
|
76
|
+
console.log('No sync history found.');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log('\n--- Sync History ---\n');
|
|
81
|
+
|
|
82
|
+
const recent = history.slice(-20);
|
|
83
|
+
for (const entry of recent) {
|
|
84
|
+
const date = new Date(entry.timestamp).toLocaleString();
|
|
85
|
+
const status = entry.success ? '\x1b[32m\u2713\x1b[0m' : '\x1b[31m\u2717\x1b[0m';
|
|
86
|
+
const dir = entry.direction === 'pull' ? '\u2193 pull' : '\u2191 push';
|
|
87
|
+
const folders = entry.folders.map(f => f.name).join(', ');
|
|
88
|
+
const stats = `${entry.total - entry.failed}/${entry.total} ok`;
|
|
89
|
+
|
|
90
|
+
console.log(` ${status} ${date} ${dir} [${folders}] ${stats}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log(`\nShowing last ${recent.length} of ${history.length} entries.`);
|
|
94
|
+
}
|
package/lib/hooks.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Execute a hook command.
|
|
5
|
+
* @param {string} name - Hook name (beforeSync, afterSync)
|
|
6
|
+
* @param {string} command - Shell command to execute
|
|
7
|
+
* @param {boolean} quiet - Suppress output
|
|
8
|
+
*/
|
|
9
|
+
export function runHook(name, command, quiet = false) {
|
|
10
|
+
if (!command) return;
|
|
11
|
+
|
|
12
|
+
if (!quiet) {
|
|
13
|
+
console.log(`\x1b[90m[hook:${name}] ${command}\x1b[0m`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
execSync(command, {
|
|
18
|
+
stdio: quiet ? 'ignore' : 'inherit',
|
|
19
|
+
timeout: 30000,
|
|
20
|
+
shell: true,
|
|
21
|
+
});
|
|
22
|
+
} catch (err) {
|
|
23
|
+
const msg = err.status ? `exited with code ${err.status}` : err.message;
|
|
24
|
+
console.error(`\x1b[33mWarning: hook '${name}' failed: ${msg}\x1b[0m`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { extractFolderName } from './config.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Interactive path selection.
|
|
6
|
+
* Returns filtered paths array.
|
|
7
|
+
*/
|
|
8
|
+
export async function selectPaths(paths) {
|
|
9
|
+
console.log('\nAvailable folders:\n');
|
|
10
|
+
|
|
11
|
+
const folders = paths.map((p, i) => {
|
|
12
|
+
const name = extractFolderName(p);
|
|
13
|
+
console.log(` ${i + 1}) ${name} (${p})`);
|
|
14
|
+
return { index: i, name, path: p };
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
console.log(` a) All\n`);
|
|
18
|
+
|
|
19
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
20
|
+
|
|
21
|
+
const answer = await new Promise((resolve) => {
|
|
22
|
+
rl.question('Select folders (comma-separated numbers or "a" for all): ', resolve);
|
|
23
|
+
});
|
|
24
|
+
rl.close();
|
|
25
|
+
|
|
26
|
+
const input = answer.trim().toLowerCase();
|
|
27
|
+
|
|
28
|
+
if (input === 'a' || input === 'all' || input === '') {
|
|
29
|
+
return paths;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const indices = input.split(',')
|
|
33
|
+
.map(s => parseInt(s.trim(), 10) - 1)
|
|
34
|
+
.filter(i => i >= 0 && i < paths.length);
|
|
35
|
+
|
|
36
|
+
if (indices.length === 0) {
|
|
37
|
+
console.log('No valid selection, using all folders.');
|
|
38
|
+
return paths;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const selected = indices.map(i => paths[i]);
|
|
42
|
+
const names = indices.map(i => folders[i].name).join(', ');
|
|
43
|
+
console.log(`\nSelected: ${names}\n`);
|
|
44
|
+
|
|
45
|
+
return selected;
|
|
46
|
+
}
|
package/lib/progress.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* Parse rsync stdout output in real-time.
|
|
3
3
|
* - Lines NOT starting with whitespace = filenames (from -v verbose)
|
|
4
4
|
* - Lines starting with whitespace = progress updates from --info=progress2
|
|
5
|
+
* @param {object} stdout - Readable stream
|
|
6
|
+
* @param {object} [options]
|
|
7
|
+
* @param {boolean} [options.verbose] - Show extra detail
|
|
5
8
|
*/
|
|
6
|
-
export function streamProgress(stdout) {
|
|
9
|
+
export function streamProgress(stdout, options = {}) {
|
|
7
10
|
let buffer = '';
|
|
8
11
|
|
|
9
12
|
stdout.on('data', (chunk) => {
|
package/lib/sync.js
CHANGED
|
@@ -17,8 +17,19 @@ export async function checkRsync() {
|
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Build rsync arguments for a single path sync.
|
|
20
|
+
* @param {string} user
|
|
21
|
+
* @param {string} host
|
|
22
|
+
* @param {string} remotePath
|
|
23
|
+
* @param {string} localPath
|
|
24
|
+
* @param {string} direction - 'pull' or 'push'
|
|
25
|
+
* @param {object} [options]
|
|
26
|
+
* @param {boolean} [options.dryRun] - Preview only
|
|
27
|
+
* @param {boolean} [options.verbose] - Extra verbosity
|
|
28
|
+
* @param {string[]} [options.exclude] - Exclude patterns
|
|
29
|
+
* @param {number} [options.bwlimit] - Bandwidth limit in KB/s
|
|
30
|
+
* @param {boolean} [options.itemize] - Show itemized changes (for diff preview)
|
|
20
31
|
*/
|
|
21
|
-
export function buildRsyncArgs(user, host, remotePath, localPath, direction) {
|
|
32
|
+
export function buildRsyncArgs(user, host, remotePath, localPath, direction, options = {}) {
|
|
22
33
|
const remote = `${user}@${host}:${remotePath.replace(/\/+$/, '')}/`;
|
|
23
34
|
const local = `${localPath.replace(/\/+$/, '')}/`;
|
|
24
35
|
|
|
@@ -30,9 +41,32 @@ export function buildRsyncArgs(user, host, remotePath, localPath, direction) {
|
|
|
30
41
|
'--info=progress2',
|
|
31
42
|
'--filter=:- .gitignore',
|
|
32
43
|
'-e', 'ssh',
|
|
33
|
-
'--', // terminate option parsing to prevent injection
|
|
34
44
|
];
|
|
35
45
|
|
|
46
|
+
if (options.dryRun) {
|
|
47
|
+
args.push('--dry-run');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (options.verbose) {
|
|
51
|
+
args.push('--verbose');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (options.itemize) {
|
|
55
|
+
args.push('--itemize-changes');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (options.bwlimit) {
|
|
59
|
+
args.push(`--bwlimit=${options.bwlimit}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (options.exclude) {
|
|
63
|
+
for (const pattern of options.exclude) {
|
|
64
|
+
args.push(`--exclude=${pattern}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
args.push('--'); // terminate option parsing to prevent injection
|
|
69
|
+
|
|
36
70
|
if (direction === 'pull') {
|
|
37
71
|
args.push(remote, local);
|
|
38
72
|
} else {
|
|
@@ -44,10 +78,15 @@ export function buildRsyncArgs(user, host, remotePath, localPath, direction) {
|
|
|
44
78
|
|
|
45
79
|
/**
|
|
46
80
|
* Execute sync for all configured remote paths.
|
|
81
|
+
* @param {object} config - Parsed config
|
|
82
|
+
* @param {string} direction - 'pull' or 'push'
|
|
83
|
+
* @param {object} [options] - CLI options
|
|
47
84
|
*/
|
|
48
|
-
export async function syncAll(config, direction) {
|
|
85
|
+
export async function syncAll(config, direction, options = {}) {
|
|
49
86
|
const results = [];
|
|
50
87
|
const { host, user, paths } = config.remote;
|
|
88
|
+
const mergedExclude = [...(config.exclude || []), ...(options.exclude || [])];
|
|
89
|
+
const mergedBwlimit = options.bwlimit || config.bwlimit || null;
|
|
51
90
|
|
|
52
91
|
for (const remotePath of paths) {
|
|
53
92
|
let folderName;
|
|
@@ -60,7 +99,9 @@ export async function syncAll(config, direction) {
|
|
|
60
99
|
|
|
61
100
|
// For push: check local folder exists
|
|
62
101
|
if (direction === 'push' && !existsSync(folderName)) {
|
|
63
|
-
|
|
102
|
+
if (!options.quiet) {
|
|
103
|
+
console.log(`\x1b[33mWarning: local folder '${folderName}' does not exist, skipping push\x1b[0m`);
|
|
104
|
+
}
|
|
64
105
|
results.push({
|
|
65
106
|
folderName,
|
|
66
107
|
remotePath,
|
|
@@ -70,28 +111,95 @@ export async function syncAll(config, direction) {
|
|
|
70
111
|
continue;
|
|
71
112
|
}
|
|
72
113
|
|
|
73
|
-
|
|
74
|
-
|
|
114
|
+
if (!options.quiet) {
|
|
115
|
+
const label = direction === 'pull' ? 'Pulling' : 'Pushing';
|
|
116
|
+
const dryLabel = options.dryRun ? ' (dry-run)' : '';
|
|
117
|
+
console.log(`\n\x1b[1m${label} ${folderName}\x1b[0m (${remotePath})${dryLabel}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const rsyncOptions = {
|
|
121
|
+
dryRun: options.dryRun,
|
|
122
|
+
verbose: options.verbose,
|
|
123
|
+
exclude: mergedExclude,
|
|
124
|
+
bwlimit: mergedBwlimit,
|
|
125
|
+
};
|
|
75
126
|
|
|
76
|
-
const args = buildRsyncArgs(user, host, remotePath, folderName, direction);
|
|
77
|
-
const result = await runRsync(args, folderName, remotePath);
|
|
127
|
+
const args = buildRsyncArgs(user, host, remotePath, folderName, direction, rsyncOptions);
|
|
128
|
+
const result = await runRsync(args, folderName, remotePath, options);
|
|
78
129
|
results.push(result);
|
|
79
130
|
}
|
|
80
131
|
|
|
81
132
|
return results;
|
|
82
133
|
}
|
|
83
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Run rsync in diff/preview mode — returns itemized changes.
|
|
137
|
+
*/
|
|
138
|
+
export async function diffPreview(config, direction, options = {}) {
|
|
139
|
+
const results = [];
|
|
140
|
+
const { host, user, paths } = config.remote;
|
|
141
|
+
const mergedExclude = [...(config.exclude || []), ...(options.exclude || [])];
|
|
142
|
+
|
|
143
|
+
for (const remotePath of paths) {
|
|
144
|
+
let folderName;
|
|
145
|
+
try {
|
|
146
|
+
folderName = extractFolderName(remotePath);
|
|
147
|
+
} catch {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const rsyncOptions = {
|
|
152
|
+
dryRun: true,
|
|
153
|
+
itemize: true,
|
|
154
|
+
exclude: mergedExclude,
|
|
155
|
+
bwlimit: options.bwlimit || config.bwlimit || null,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const args = buildRsyncArgs(user, host, remotePath, folderName, direction, rsyncOptions);
|
|
159
|
+
const changes = await runRsyncCapture(args);
|
|
160
|
+
if (changes.length > 0) {
|
|
161
|
+
results.push({ folderName, remotePath, changes });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return results;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Run rsync and capture full output (for diff preview).
|
|
170
|
+
*/
|
|
171
|
+
function runRsyncCapture(args) {
|
|
172
|
+
return new Promise((resolve) => {
|
|
173
|
+
const child = spawn('rsync', args, {
|
|
174
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
let stdout = '';
|
|
178
|
+
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
179
|
+
child.stderr.resume();
|
|
180
|
+
child.on('error', () => resolve([]));
|
|
181
|
+
child.on('close', () => {
|
|
182
|
+
const lines = stdout.split('\n').filter(l => l.trim() && !l.startsWith(' ') && l !== './');
|
|
183
|
+
resolve(lines);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
84
188
|
/**
|
|
85
189
|
* Run a single rsync command and return the result.
|
|
86
190
|
*/
|
|
87
|
-
function runRsync(args, folderName, remotePath) {
|
|
191
|
+
function runRsync(args, folderName, remotePath, options = {}) {
|
|
88
192
|
return new Promise((resolve) => {
|
|
89
193
|
const child = spawn('rsync', args, {
|
|
90
194
|
stdio: ['inherit', 'pipe', 'pipe'], // stdin inherited for SSH password prompts
|
|
91
195
|
});
|
|
92
196
|
|
|
93
|
-
// Stream stdout for progress display
|
|
94
|
-
|
|
197
|
+
// Stream stdout for progress display (unless quiet)
|
|
198
|
+
if (!options.quiet) {
|
|
199
|
+
streamProgress(child.stdout, options);
|
|
200
|
+
} else {
|
|
201
|
+
child.stdout.resume(); // drain stdout to prevent backpressure
|
|
202
|
+
}
|
|
95
203
|
|
|
96
204
|
// Capture stderr
|
|
97
205
|
let stderr = '';
|
package/lib/watch.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { watch as fsWatch } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { extractFolderName } from './config.js';
|
|
4
|
+
import { syncAll } from './sync.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Watch local folders for changes and auto-push.
|
|
8
|
+
*/
|
|
9
|
+
export function startWatch(config, options = {}) {
|
|
10
|
+
const { paths } = config.remote;
|
|
11
|
+
const debounceMs = 500;
|
|
12
|
+
let timer = null;
|
|
13
|
+
let syncing = false;
|
|
14
|
+
|
|
15
|
+
const folders = paths.map(p => extractFolderName(p));
|
|
16
|
+
|
|
17
|
+
console.log('\x1b[1mWatching for changes...\x1b[0m');
|
|
18
|
+
for (const folder of folders) {
|
|
19
|
+
console.log(` \x1b[36m→\x1b[0m ${folder}/`);
|
|
20
|
+
}
|
|
21
|
+
console.log('\nPress Ctrl+C to stop.\n');
|
|
22
|
+
|
|
23
|
+
const watchers = [];
|
|
24
|
+
|
|
25
|
+
for (const folder of folders) {
|
|
26
|
+
const absPath = resolve(folder);
|
|
27
|
+
try {
|
|
28
|
+
const watcher = fsWatch(absPath, { recursive: true }, (eventType, filename) => {
|
|
29
|
+
if (filename && (filename.startsWith('.') || filename.endsWith('~') || filename.endsWith('.swp'))) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (timer) clearTimeout(timer);
|
|
34
|
+
timer = setTimeout(async () => {
|
|
35
|
+
if (syncing) return;
|
|
36
|
+
syncing = true;
|
|
37
|
+
|
|
38
|
+
const time = new Date().toLocaleTimeString();
|
|
39
|
+
console.log(`\x1b[90m[${time}]\x1b[0m Change detected${filename ? `: ${filename}` : ''}`);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await syncAll(config, 'push', options);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error(`\x1b[31mSync error: ${err.message}\x1b[0m`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
syncing = false;
|
|
48
|
+
console.log('\x1b[90mWatching for changes...\x1b[0m\n');
|
|
49
|
+
}, debounceMs);
|
|
50
|
+
});
|
|
51
|
+
watchers.push(watcher);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error(`\x1b[33mWarning: cannot watch '${folder}': ${err.message}\x1b[0m`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
process.on('SIGINT', () => {
|
|
58
|
+
console.log('\n\x1b[1mStopping watch...\x1b[0m');
|
|
59
|
+
for (const w of watchers) w.close();
|
|
60
|
+
process.exit(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return watchers;
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "island-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Sync remote server folders to local directory via rsync over SSH",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"lib/"
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "node --test test
|
|
14
|
+
"test": "node --test test/*.test.js"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
17
|
"rsync",
|