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 +74 -0
- package/bin/cli.js +67 -0
- package/lib/config.js +86 -0
- package/lib/progress.js +53 -0
- package/lib/summary.js +35 -0
- package/lib/sync.js +127 -0
- package/package.json +34 -0
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
|
+
}
|
package/lib/progress.js
ADDED
|
@@ -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
|
+
}
|