island-bridge 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # island-bridge
2
+
3
+ Sync remote server folders to local directory via rsync over SSH.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g island-bridge
9
+ ```
10
+
11
+ Or use directly with npx:
12
+
13
+ ```bash
14
+ npx island-bridge pull
15
+ npx island-bridge push
16
+ ```
17
+
18
+ ## Prerequisites
19
+
20
+ - Node.js >= 18
21
+ - `rsync` installed on both local and remote machines
22
+ - SSH access to remote server (uses system `~/.ssh/config`)
23
+
24
+ ## Usage
25
+
26
+ 1. Create `island-bridge.json` in your working directory:
27
+
28
+ ```json
29
+ {
30
+ "remote": {
31
+ "host": "192.168.1.100",
32
+ "user": "deploy",
33
+ "paths": [
34
+ "/var/www/app",
35
+ "/etc/nginx/conf.d",
36
+ "/home/deploy/scripts"
37
+ ]
38
+ }
39
+ }
40
+ ```
41
+
42
+ 2. Pull remote folders to local:
43
+
44
+ ```bash
45
+ island-bridge pull
46
+ ```
47
+
48
+ This creates local subdirectories matching the remote folder names:
49
+
50
+ ```
51
+ ./app/ <- from /var/www/app
52
+ ./conf.d/ <- from /etc/nginx/conf.d
53
+ ./scripts/ <- from /home/deploy/scripts
54
+ ```
55
+
56
+ 3. Push local changes back to remote:
57
+
58
+ ```bash
59
+ island-bridge push
60
+ ```
61
+
62
+ ## Features
63
+
64
+ - **Bidirectional sync** — `pull` downloads, `push` uploads
65
+ - **Multi-folder** — sync multiple remote paths in one config
66
+ - **rsync over SSH** — uses system SSH config for authentication
67
+ - **Auto .gitignore** — respects `.gitignore` exclusion rules
68
+ - **Progress display** — real-time per-file transfer with color output
69
+ - **Fault tolerant** — skips failed transfers, reports summary at end
70
+ - **Zero dependencies** — pure Node.js, no npm dependencies
71
+
72
+ ## License
73
+
74
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { loadConfig } from '../lib/config.js';
4
+ import { checkRsync, syncAll } from '../lib/sync.js';
5
+ import { printSummary } from '../lib/summary.js';
6
+
7
+ const USAGE = `
8
+ island-bridge - Sync remote folders to local directory via rsync
9
+
10
+ Usage:
11
+ island-bridge pull Pull remote folders to local directory
12
+ island-bridge push Push local folders to remote server
13
+ island-bridge --help Show this help message
14
+
15
+ Config:
16
+ Place an island-bridge.json in the working directory:
17
+ {
18
+ "remote": {
19
+ "host": "192.168.1.100",
20
+ "user": "deploy",
21
+ "paths": ["/var/www/app", "/etc/nginx/conf.d"]
22
+ }
23
+ }
24
+ `.trim();
25
+
26
+ async function main() {
27
+ const command = process.argv[2];
28
+
29
+ if (!command || command === '--help' || command === '-h') {
30
+ console.log(USAGE);
31
+ process.exit(0);
32
+ }
33
+
34
+ if (command !== 'pull' && command !== 'push') {
35
+ console.error(`Unknown command: ${command}`);
36
+ console.error('Use "island-bridge pull" or "island-bridge push"');
37
+ process.exit(1);
38
+ }
39
+
40
+ // Pre-flight: check rsync
41
+ const rsyncOk = await checkRsync();
42
+ if (!rsyncOk) {
43
+ console.error('\x1b[31mError: rsync is required but not found in PATH. Install rsync and try again.\x1b[0m');
44
+ process.exit(1);
45
+ }
46
+
47
+ // Load config
48
+ let config;
49
+ try {
50
+ config = loadConfig();
51
+ } catch (err) {
52
+ console.error(`\x1b[31mError: ${err.message}\x1b[0m`);
53
+ process.exit(1);
54
+ }
55
+
56
+ // Execute sync
57
+ const results = await syncAll(config, command);
58
+
59
+ // Print summary
60
+ printSummary(results);
61
+
62
+ // Exit with non-zero code if any transfers failed
63
+ const hasFailed = results.some(r => !r.success);
64
+ process.exit(hasFailed ? 1 : 0);
65
+ }
66
+
67
+ main();
package/lib/config.js ADDED
@@ -0,0 +1,86 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { basename } from 'node:path';
3
+
4
+ const CONFIG_FILE = 'island-bridge.json';
5
+
6
+ /**
7
+ * Load and validate config from island-bridge.json in cwd.
8
+ */
9
+ export function loadConfig() {
10
+ let raw;
11
+ try {
12
+ raw = readFileSync(CONFIG_FILE, 'utf-8');
13
+ } catch {
14
+ throw new Error(`Failed to read ${CONFIG_FILE} from current directory`);
15
+ }
16
+
17
+ let config;
18
+ try {
19
+ config = JSON.parse(raw);
20
+ } catch {
21
+ throw new Error(`Failed to parse ${CONFIG_FILE}: invalid JSON`);
22
+ }
23
+
24
+ validate(config);
25
+ return config;
26
+ }
27
+
28
+ /**
29
+ * Validate config structure and values.
30
+ */
31
+ function validate(config) {
32
+ if (!config.remote) {
33
+ throw new Error("Config error: missing 'remote' section");
34
+ }
35
+
36
+ const { host, user, paths } = config.remote;
37
+
38
+ if (!host || typeof host !== 'string' || host.trim() === '') {
39
+ throw new Error("Config error: 'host' must be a non-empty string");
40
+ }
41
+ if (host.startsWith('-') || /\s/.test(host)) {
42
+ throw new Error("Config error: 'host' must not start with '-' or contain whitespace");
43
+ }
44
+
45
+ if (!user || typeof user !== 'string' || user.trim() === '') {
46
+ throw new Error("Config error: 'user' must be a non-empty string");
47
+ }
48
+ if (user.startsWith('-') || /\s/.test(user)) {
49
+ throw new Error("Config error: 'user' must not start with '-' or contain whitespace");
50
+ }
51
+
52
+ if (!Array.isArray(paths) || paths.length === 0) {
53
+ throw new Error("Config error: 'paths' must be a non-empty array of remote paths");
54
+ }
55
+
56
+ const seen = new Set();
57
+ for (const p of paths) {
58
+ if (typeof p !== 'string' || p.trim() === '') {
59
+ throw new Error(`Config error: each path must be a non-empty string`);
60
+ }
61
+ if (p.startsWith('-')) {
62
+ throw new Error(`Config error: remote path '${p}' must not start with '-'`);
63
+ }
64
+ const name = extractFolderName(p);
65
+ if (seen.has(name)) {
66
+ throw new Error(`Config error: folder name collision — multiple remote paths resolve to '${name}'`);
67
+ }
68
+ seen.add(name);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Extract folder name (last path component) from a remote path.
74
+ * Strips trailing slashes. Rejects root "/" and empty strings.
75
+ */
76
+ export function extractFolderName(remotePath) {
77
+ const trimmed = remotePath.replace(/\/+$/, '');
78
+ if (trimmed === '' || trimmed === '/') {
79
+ throw new Error(`Config error: remote path '${remotePath}' resolves to root or is empty`);
80
+ }
81
+ const name = basename(trimmed);
82
+ if (!name) {
83
+ throw new Error(`Config error: cannot extract folder name from '${remotePath}'`);
84
+ }
85
+ return name;
86
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Parse rsync stdout output in real-time.
3
+ * - Lines NOT starting with whitespace = filenames (from -v verbose)
4
+ * - Lines starting with whitespace = progress updates from --info=progress2
5
+ */
6
+ export function streamProgress(stdout) {
7
+ let buffer = '';
8
+
9
+ stdout.on('data', (chunk) => {
10
+ buffer += chunk.toString();
11
+
12
+ // Process complete lines
13
+ const lines = buffer.split('\n');
14
+ buffer = lines.pop(); // keep incomplete line in buffer
15
+
16
+ for (const line of lines) {
17
+ // Handle \r-delimited progress updates
18
+ const parts = line.split('\r');
19
+ const displayLine = parts[parts.length - 1];
20
+
21
+ if (!displayLine || displayLine.trim() === '') continue;
22
+
23
+ if (/^\s/.test(displayLine)) {
24
+ // Progress line from --info=progress2
25
+ process.stdout.write(`\r\x1b[K \x1b[36m${displayLine.trim()}\x1b[0m`);
26
+ } else {
27
+ const trimmed = displayLine.trim();
28
+ if (trimmed === './' || trimmed === '') continue;
29
+
30
+ if (trimmed.startsWith('deleting ')) {
31
+ // Deletion highlighted in yellow
32
+ process.stdout.write(`\r\x1b[K \x1b[33m- ${trimmed.slice(9)}\x1b[0m\n`);
33
+ } else {
34
+ // New/updated file in green
35
+ process.stdout.write(`\r\x1b[K \x1b[32m+ ${trimmed}\x1b[0m\n`);
36
+ }
37
+ }
38
+ }
39
+ });
40
+
41
+ stdout.on('end', () => {
42
+ // Process remaining buffer
43
+ if (buffer.trim()) {
44
+ const parts = buffer.split('\r');
45
+ const displayLine = parts[parts.length - 1]?.trim();
46
+ if (displayLine && displayLine !== './') {
47
+ process.stdout.write(`\r\x1b[K \x1b[32m+ ${displayLine}\x1b[0m\n`);
48
+ }
49
+ }
50
+ // Clear progress line
51
+ process.stdout.write('\r\x1b[K');
52
+ });
53
+ }
package/lib/summary.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Map rsync exit codes to human-readable messages.
3
+ */
4
+ export function rsyncExitMessage(code) {
5
+ const messages = {
6
+ 0: 'success',
7
+ 11: 'disk full or quota exceeded',
8
+ 12: 'rsync protocol error: possible network issue',
9
+ 23: 'partial transfer: some files could not be synced',
10
+ 24: 'vanished source files: some files disappeared during transfer',
11
+ 30: 'timeout waiting for response',
12
+ 35: 'timeout waiting for daemon connection',
13
+ 255: 'SSH connection failed: check your SSH config and connectivity',
14
+ };
15
+ return messages[code] || `rsync failed with exit code ${code}`;
16
+ }
17
+
18
+ /**
19
+ * Print a summary of sync results.
20
+ */
21
+ export function printSummary(results) {
22
+ console.log('\n--- Sync Summary ---');
23
+
24
+ for (const r of results) {
25
+ if (r.success) {
26
+ console.log(` \x1b[32m✓\x1b[0m ${r.folderName} — synced successfully`);
27
+ } else {
28
+ console.log(` \x1b[31m✗\x1b[0m ${r.folderName} — ${r.error}`);
29
+ }
30
+ }
31
+
32
+ const failed = results.filter(r => !r.success).length;
33
+ const total = results.length;
34
+ console.log(`\n${total - failed}/${total} folders synced successfully.`);
35
+ }
package/lib/sync.js ADDED
@@ -0,0 +1,127 @@
1
+ import { spawn, execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { extractFolderName } from './config.js';
4
+ import { streamProgress } from './progress.js';
5
+ import { rsyncExitMessage } from './summary.js';
6
+
7
+ /**
8
+ * Check that rsync is available in PATH.
9
+ */
10
+ export async function checkRsync() {
11
+ return new Promise((resolve) => {
12
+ execFile('rsync', ['--version'], (err) => {
13
+ resolve(!err);
14
+ });
15
+ });
16
+ }
17
+
18
+ /**
19
+ * Build rsync arguments for a single path sync.
20
+ */
21
+ export function buildRsyncArgs(user, host, remotePath, localPath, direction) {
22
+ const remote = `${user}@${host}:${remotePath.replace(/\/+$/, '')}/`;
23
+ const local = `${localPath.replace(/\/+$/, '')}/`;
24
+
25
+ const args = [
26
+ '-avz',
27
+ '--delete',
28
+ '--no-owner',
29
+ '--no-group',
30
+ '--info=progress2',
31
+ '--filter=:- .gitignore',
32
+ '-e', 'ssh',
33
+ '--', // terminate option parsing to prevent injection
34
+ ];
35
+
36
+ if (direction === 'pull') {
37
+ args.push(remote, local);
38
+ } else {
39
+ args.push(local, remote);
40
+ }
41
+
42
+ return args;
43
+ }
44
+
45
+ /**
46
+ * Execute sync for all configured remote paths.
47
+ */
48
+ export async function syncAll(config, direction) {
49
+ const results = [];
50
+ const { host, user, paths } = config.remote;
51
+
52
+ for (const remotePath of paths) {
53
+ let folderName;
54
+ try {
55
+ folderName = extractFolderName(remotePath);
56
+ } catch (err) {
57
+ results.push({ folderName: remotePath, remotePath, success: false, error: err.message });
58
+ continue;
59
+ }
60
+
61
+ // For push: check local folder exists
62
+ if (direction === 'push' && !existsSync(folderName)) {
63
+ console.log(`\x1b[33mWarning: local folder '${folderName}' does not exist, skipping push\x1b[0m`);
64
+ results.push({
65
+ folderName,
66
+ remotePath,
67
+ success: false,
68
+ error: `local folder '${folderName}' does not exist`,
69
+ });
70
+ continue;
71
+ }
72
+
73
+ const label = direction === 'pull' ? 'Pulling' : 'Pushing';
74
+ console.log(`\n\x1b[1m${label} ${folderName}\x1b[0m (${remotePath})`);
75
+
76
+ const args = buildRsyncArgs(user, host, remotePath, folderName, direction);
77
+ const result = await runRsync(args, folderName, remotePath);
78
+ results.push(result);
79
+ }
80
+
81
+ return results;
82
+ }
83
+
84
+ /**
85
+ * Run a single rsync command and return the result.
86
+ */
87
+ function runRsync(args, folderName, remotePath) {
88
+ return new Promise((resolve) => {
89
+ const child = spawn('rsync', args, {
90
+ stdio: ['inherit', 'pipe', 'pipe'], // stdin inherited for SSH password prompts
91
+ });
92
+
93
+ // Stream stdout for progress display
94
+ streamProgress(child.stdout);
95
+
96
+ // Capture stderr
97
+ let stderr = '';
98
+ child.stderr.on('data', (data) => {
99
+ stderr += data.toString();
100
+ });
101
+
102
+ child.on('error', (err) => {
103
+ resolve({
104
+ folderName,
105
+ remotePath,
106
+ success: false,
107
+ error: err.message,
108
+ });
109
+ });
110
+
111
+ child.on('close', (code) => {
112
+ if (code === 0) {
113
+ resolve({ folderName, remotePath, success: true, error: null });
114
+ } else {
115
+ const exitCode = code ?? -1;
116
+ const message = rsyncExitMessage(exitCode);
117
+ resolve({
118
+ folderName,
119
+ remotePath,
120
+ success: false,
121
+ error: `${message}${stderr.trim() ? ` (${stderr.trim()})` : ''}`,
122
+ exitCode,
123
+ });
124
+ }
125
+ });
126
+ });
127
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "island-bridge",
3
+ "version": "1.0.0",
4
+ "description": "Sync remote server folders to local directory via rsync over SSH",
5
+ "type": "module",
6
+ "bin": {
7
+ "island-bridge": "./bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/"
12
+ ],
13
+ "scripts": {
14
+ "test": "node --test test/config.test.js"
15
+ },
16
+ "keywords": [
17
+ "rsync",
18
+ "sync",
19
+ "ssh",
20
+ "cli",
21
+ "remote",
22
+ "deploy",
23
+ "devtools"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/gong1414/island-bridge.git"
28
+ },
29
+ "author": "gong1414",
30
+ "license": "MIT",
31
+ "engines": {
32
+ "node": ">=18"
33
+ }
34
+ }