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 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 to local directory via rsync
19
+ island-bridge v${pkg.version} - Sync remote folders via rsync over SSH
9
20
 
10
21
  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
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
- "host": "192.168.1.100",
20
- "user": "deploy",
21
- "paths": ["/var/www/app", "/etc/nginx/conf.d"]
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
- const command = process.argv[2];
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 (!command || command === '--help' || command === '-h') {
61
+ if (args.help || (!args.command && !args.version)) {
30
62
  console.log(USAGE);
31
63
  process.exit(0);
32
64
  }
33
65
 
34
- if (command !== 'pull' && command !== 'push') {
35
- console.error(`Unknown command: ${command}`);
36
- console.error('Use "island-bridge pull" or "island-bridge push"');
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
- // Execute sync
57
- const results = await syncAll(config, command);
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
- // Print summary
60
- printSummary(results);
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
- * Load and validate config from island-bridge.json in cwd.
7
+ * Search for config file starting from startDir, walking up to root.
8
8
  */
9
- export function loadConfig() {
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(CONFIG_FILE, 'utf-8');
46
+ raw = readFileSync(filePath, 'utf-8');
13
47
  } catch {
14
- throw new Error(`Failed to read ${CONFIG_FILE} from current directory`);
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 ${CONFIG_FILE}: invalid JSON`);
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 (host.startsWith('-') || /\s/.test(host)) {
42
- throw new Error("Config error: 'host' must not start with '-' or contain whitespace");
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 (user.startsWith('-') || /\s/.test(user)) {
49
- throw new Error("Config error: 'user' must not start with '-' or contain whitespace");
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
- console.log(`\x1b[33mWarning: local folder '${folderName}' does not exist, skipping push\x1b[0m`);
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
- const label = direction === 'pull' ? 'Pulling' : 'Pushing';
74
- console.log(`\n\x1b[1m${label} ${folderName}\x1b[0m (${remotePath})`);
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
- streamProgress(child.stdout);
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.0.0",
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/config.test.js"
14
+ "test": "node --test test/*.test.js"
15
15
  },
16
16
  "keywords": [
17
17
  "rsync",