@ongtrieuhau861457/runner-tailscale-sync 1.260202.11920
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 +310 -0
- package/bin/runner-sync.js +85 -0
- package/package.json +41 -0
- package/src/adapters/fs.js +160 -0
- package/src/adapters/git.js +185 -0
- package/src/adapters/http.js +56 -0
- package/src/adapters/process.js +151 -0
- package/src/adapters/ssh.js +103 -0
- package/src/adapters/tailscale.js +406 -0
- package/src/cli/commands/init.js +13 -0
- package/src/cli/commands/push.js +13 -0
- package/src/cli/commands/status.js +13 -0
- package/src/cli/commands/sync.js +22 -0
- package/src/cli/parser.js +114 -0
- package/src/core/data-sync.js +177 -0
- package/src/core/init.js +141 -0
- package/src/core/push.js +113 -0
- package/src/core/runner-detector.js +167 -0
- package/src/core/service-controller.js +141 -0
- package/src/core/status.js +130 -0
- package/src/core/sync-orchestrator.js +260 -0
- package/src/index.js +140 -0
- package/src/utils/config.js +129 -0
- package/src/utils/constants.js +33 -0
- package/src/utils/errors.js +45 -0
- package/src/utils/logger.js +154 -0
- package/src/utils/time.js +65 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adapters/git.js
|
|
3
|
+
* Git operations: push, pull, conflict resolution
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const process_adapter = require("./process");
|
|
7
|
+
const { ProcessError } = require("../utils/errors");
|
|
8
|
+
const CONST = require("../utils/constants");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if git is available
|
|
12
|
+
*/
|
|
13
|
+
function isAvailable() {
|
|
14
|
+
return process_adapter.commandExists("git");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Check if current directory is a git repo
|
|
19
|
+
*/
|
|
20
|
+
function isGitRepo(cwd) {
|
|
21
|
+
try {
|
|
22
|
+
const output = process_adapter.runCapture("git rev-parse --git-dir", { cwd });
|
|
23
|
+
return output !== null;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Add files to git
|
|
31
|
+
*/
|
|
32
|
+
function add(files, options = {}) {
|
|
33
|
+
const { logger, cwd } = options;
|
|
34
|
+
|
|
35
|
+
const filesStr = Array.isArray(files) ? files.join(" ") : files;
|
|
36
|
+
const cmd = `git add ${filesStr}`;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
process_adapter.run(cmd, { logger, cwd, ignoreError: false });
|
|
40
|
+
return true;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
throw new ProcessError(`Git add failed: ${err.message}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Commit changes
|
|
48
|
+
*/
|
|
49
|
+
function commit(message, options = {}) {
|
|
50
|
+
const { logger, cwd } = options;
|
|
51
|
+
|
|
52
|
+
const safeMessage = message.replace(/"/g, '\\"');
|
|
53
|
+
const cmd = `git commit -m "${safeMessage}"`;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
process_adapter.run(cmd, { logger, cwd, ignoreError: false });
|
|
57
|
+
return true;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
// No changes to commit is OK
|
|
60
|
+
if (err.message && err.message.includes("nothing to commit")) {
|
|
61
|
+
if (logger) {
|
|
62
|
+
logger.debug("No changes to commit");
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
throw new ProcessError(`Git commit failed: ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Push to remote
|
|
72
|
+
*/
|
|
73
|
+
async function push(branch, options = {}) {
|
|
74
|
+
const { logger, cwd, retries = CONST.GIT_RETRY_COUNT } = options;
|
|
75
|
+
|
|
76
|
+
const cmd = `git push origin ${branch}`;
|
|
77
|
+
|
|
78
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
79
|
+
try {
|
|
80
|
+
process_adapter.run(cmd, { logger, cwd, ignoreError: false });
|
|
81
|
+
if (logger) {
|
|
82
|
+
logger.success(`Pushed to ${branch}`);
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (attempt < retries) {
|
|
87
|
+
if (logger) {
|
|
88
|
+
logger.warn(`Push failed (attempt ${attempt}/${retries}), retrying...`);
|
|
89
|
+
}
|
|
90
|
+
await process_adapter.sleep(CONST.GIT_RETRY_DELAY);
|
|
91
|
+
} else {
|
|
92
|
+
throw new ProcessError(`Git push failed after ${retries} attempts: ${err.message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pull from remote
|
|
100
|
+
*/
|
|
101
|
+
async function pull(branch, options = {}) {
|
|
102
|
+
const { logger, cwd, retries = CONST.GIT_RETRY_COUNT } = options;
|
|
103
|
+
|
|
104
|
+
const cmd = `git pull origin ${branch}`;
|
|
105
|
+
|
|
106
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
107
|
+
try {
|
|
108
|
+
process_adapter.run(cmd, { logger, cwd, ignoreError: false });
|
|
109
|
+
if (logger) {
|
|
110
|
+
logger.success(`Pulled from ${branch}`);
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (attempt < retries) {
|
|
115
|
+
if (logger) {
|
|
116
|
+
logger.warn(`Pull failed (attempt ${attempt}/${retries}), retrying...`);
|
|
117
|
+
}
|
|
118
|
+
await process_adapter.sleep(CONST.GIT_RETRY_DELAY);
|
|
119
|
+
} else {
|
|
120
|
+
throw new ProcessError(`Git pull failed after ${retries} attempts: ${err.message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get current branch
|
|
128
|
+
*/
|
|
129
|
+
function getCurrentBranch(cwd) {
|
|
130
|
+
try {
|
|
131
|
+
return process_adapter.runCapture("git rev-parse --abbrev-ref HEAD", { cwd });
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Check if there are uncommitted changes
|
|
139
|
+
*/
|
|
140
|
+
function hasUncommittedChanges(cwd) {
|
|
141
|
+
try {
|
|
142
|
+
const output = process_adapter.runCapture("git status --porcelain", { cwd });
|
|
143
|
+
return output && output.length > 0;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Full workflow: add, commit, push
|
|
151
|
+
*/
|
|
152
|
+
async function commitAndPush(message, branch, options = {}) {
|
|
153
|
+
const { logger, cwd } = options;
|
|
154
|
+
|
|
155
|
+
if (!hasUncommittedChanges(cwd)) {
|
|
156
|
+
if (logger) {
|
|
157
|
+
logger.info("No changes to commit");
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Add all changes in .runner-data
|
|
163
|
+
add(".runner-data/*", { logger, cwd });
|
|
164
|
+
|
|
165
|
+
// Commit
|
|
166
|
+
const committed = commit(message, { logger, cwd });
|
|
167
|
+
if (!committed) return false;
|
|
168
|
+
|
|
169
|
+
// Push
|
|
170
|
+
await push(branch, { logger, cwd });
|
|
171
|
+
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = {
|
|
176
|
+
isAvailable,
|
|
177
|
+
isGitRepo,
|
|
178
|
+
add,
|
|
179
|
+
commit,
|
|
180
|
+
push,
|
|
181
|
+
pull,
|
|
182
|
+
getCurrentBranch,
|
|
183
|
+
hasUncommittedChanges,
|
|
184
|
+
commitAndPush,
|
|
185
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adapters/http.js
|
|
3
|
+
* HTTP fetch with timeout + retry
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { NetworkError } = require("../utils/errors");
|
|
7
|
+
|
|
8
|
+
async function fetchWithTimeout(url, options = {}) {
|
|
9
|
+
const { timeoutMs = 10000, ...rest } = options;
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const response = await fetch(url, {
|
|
15
|
+
...rest,
|
|
16
|
+
signal: controller.signal,
|
|
17
|
+
});
|
|
18
|
+
return response;
|
|
19
|
+
} catch (err) {
|
|
20
|
+
if (err.name === "AbortError") {
|
|
21
|
+
throw new NetworkError(`Request timeout after ${timeoutMs}ms: ${url}`);
|
|
22
|
+
}
|
|
23
|
+
throw new NetworkError(`Request failed: ${err.message}`);
|
|
24
|
+
} finally {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function fetchWithRetry(url, options = {}) {
|
|
30
|
+
const {
|
|
31
|
+
retries = 3,
|
|
32
|
+
retryDelayMs = 1000,
|
|
33
|
+
timeoutMs = 10000,
|
|
34
|
+
...rest
|
|
35
|
+
} = options;
|
|
36
|
+
|
|
37
|
+
let lastError;
|
|
38
|
+
|
|
39
|
+
for (let attempt = 1; attempt <= retries; attempt += 1) {
|
|
40
|
+
try {
|
|
41
|
+
return await fetchWithTimeout(url, { ...rest, timeoutMs });
|
|
42
|
+
} catch (err) {
|
|
43
|
+
lastError = err;
|
|
44
|
+
if (attempt < retries) {
|
|
45
|
+
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
throw lastError || new NetworkError(`Request failed after ${retries} attempts: ${url}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
fetchWithTimeout,
|
|
55
|
+
fetchWithRetry,
|
|
56
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adapters/process.js
|
|
3
|
+
* Cross-platform process spawning
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { execSync, spawn } = require("child_process");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
|
|
9
|
+
const isWindows = os.platform() === "win32";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Run command và wait for completion
|
|
13
|
+
*/
|
|
14
|
+
function run(cmd, options = {}) {
|
|
15
|
+
const { ignoreError = false, cwd, logger } = options;
|
|
16
|
+
|
|
17
|
+
if (logger) {
|
|
18
|
+
logger.command(cmd);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
return execSync(cmd, {
|
|
23
|
+
stdio: "inherit",
|
|
24
|
+
cwd: cwd || process.cwd(),
|
|
25
|
+
...options,
|
|
26
|
+
});
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (ignoreError) {
|
|
29
|
+
if (logger) {
|
|
30
|
+
logger.warn(`Command failed (ignored): ${cmd}`);
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run command và capture output
|
|
40
|
+
*/
|
|
41
|
+
function runCapture(cmd, options = {}) {
|
|
42
|
+
const { cwd } = options;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
return execSync(cmd, {
|
|
46
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
47
|
+
encoding: "utf8",
|
|
48
|
+
cwd: cwd || process.cwd(),
|
|
49
|
+
...options,
|
|
50
|
+
}).trim();
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check if command exists
|
|
58
|
+
*/
|
|
59
|
+
function commandExists(cmd) {
|
|
60
|
+
const check = isWindows ? `where ${cmd}` : `command -v ${cmd}`;
|
|
61
|
+
return !!runCapture(check);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run command with timeout
|
|
66
|
+
*/
|
|
67
|
+
function runWithTimeout(cmd, timeoutMs, options = {}) {
|
|
68
|
+
const { logger } = options;
|
|
69
|
+
|
|
70
|
+
// Support both string commands and argv arrays.
|
|
71
|
+
// - If cmd is an array: [exe, ...args] -> spawn(exe, args)
|
|
72
|
+
// - If cmd is a string: spawn(cmd, { shell: true }) so quoting works cross-platform
|
|
73
|
+
const useArray = Array.isArray(cmd);
|
|
74
|
+
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const spawnOptions = {
|
|
77
|
+
stdio: "inherit",
|
|
78
|
+
cwd: options.cwd || process.cwd(),
|
|
79
|
+
detached: !isWindows,
|
|
80
|
+
};
|
|
81
|
+
const child = useArray
|
|
82
|
+
? spawn(cmd[0], cmd.slice(1), spawnOptions)
|
|
83
|
+
: spawn(cmd, { ...spawnOptions, shell: true });
|
|
84
|
+
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
if (isWindows) {
|
|
87
|
+
try {
|
|
88
|
+
execSync(`taskkill /pid ${child.pid} /T /F`);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (logger) {
|
|
91
|
+
logger.warn(`Failed to terminate process tree: ${err.message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
try {
|
|
96
|
+
process.kill(-child.pid, "SIGKILL");
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (logger) {
|
|
99
|
+
logger.warn(`Failed to terminate process group: ${err.message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
reject(new Error(`Command timeout after ${timeoutMs}ms: ${useArray ? cmd.join(" ") : cmd}`));
|
|
104
|
+
}, timeoutMs);
|
|
105
|
+
|
|
106
|
+
child.on("close", (code) => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
|
|
109
|
+
if (code === 0) {
|
|
110
|
+
resolve(true);
|
|
111
|
+
} else {
|
|
112
|
+
reject(new Error(`Command failed with code ${code}: ${useArray ? cmd.join(" ") : cmd}`));
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
child.on("error", (err) => {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
reject(err);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Sleep
|
|
126
|
+
*/
|
|
127
|
+
function sleep(ms) {
|
|
128
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Wait for condition
|
|
133
|
+
*/
|
|
134
|
+
async function waitForCondition(checkFn, timeoutMs = 30000, intervalMs = 1000) {
|
|
135
|
+
const start = Date.now();
|
|
136
|
+
while (Date.now() - start < timeoutMs) {
|
|
137
|
+
if (checkFn()) return true;
|
|
138
|
+
await sleep(intervalMs);
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = {
|
|
144
|
+
run,
|
|
145
|
+
runCapture,
|
|
146
|
+
commandExists,
|
|
147
|
+
runWithTimeout,
|
|
148
|
+
sleep,
|
|
149
|
+
waitForCondition,
|
|
150
|
+
isWindows,
|
|
151
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adapters/ssh.js
|
|
3
|
+
* SSH operations: execute commands remotely
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const process_adapter = require("./process");
|
|
7
|
+
const { ProcessError } = require("../utils/errors");
|
|
8
|
+
const CONST = require("../utils/constants");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Execute command via SSH
|
|
12
|
+
*/
|
|
13
|
+
function executeCommand(host, command, options = {}) {
|
|
14
|
+
const { logger, sshPath = "ssh", timeout = CONST.SSH_TIMEOUT } = options;
|
|
15
|
+
|
|
16
|
+
// If sshPath contains spaces, spawn can still execute it if provided as argv[0].
|
|
17
|
+
// Avoid building a single shell string to keep quoting predictable.
|
|
18
|
+
const sshArgs = ["-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10", host, command];
|
|
19
|
+
|
|
20
|
+
if (logger) {
|
|
21
|
+
logger.command([sshPath, ...sshArgs].join(" "));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return process_adapter.runWithTimeout([sshPath, ...sshArgs], timeout, { logger });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Execute command và capture output
|
|
29
|
+
*/
|
|
30
|
+
function executeCommandCapture(host, command, options = {}) {
|
|
31
|
+
const { sshPath = "ssh" } = options;
|
|
32
|
+
|
|
33
|
+
const sshCmd = `${sshPath} -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${host} "${command}"`;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
return process_adapter.runCapture(sshCmd);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if SSH connection works
|
|
44
|
+
*/
|
|
45
|
+
function checkConnection(host, options = {}) {
|
|
46
|
+
const { logger, sshPath = "ssh" } = options;
|
|
47
|
+
|
|
48
|
+
if (logger) {
|
|
49
|
+
logger.debug(`Testing SSH connection to ${host}...`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const result = executeCommandCapture(host, "echo OK", { sshPath });
|
|
54
|
+
return result === "OK";
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Stop services on remote host
|
|
62
|
+
*/
|
|
63
|
+
async function stopServices(host, services, options = {}) {
|
|
64
|
+
const { logger, sshPath = "ssh" } = options;
|
|
65
|
+
|
|
66
|
+
if (!services || services.length === 0) {
|
|
67
|
+
if (logger) {
|
|
68
|
+
logger.info("No services to stop");
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
logger.info(`Stopping services on ${host}: ${services.join(", ")}`);
|
|
74
|
+
|
|
75
|
+
await Promise.all(
|
|
76
|
+
services.map(async (service) => {
|
|
77
|
+
try {
|
|
78
|
+
await executeCommand(host, `sudo systemctl stop ${service}`, {
|
|
79
|
+
logger,
|
|
80
|
+
sshPath,
|
|
81
|
+
});
|
|
82
|
+
logger.success(`Stopped service: ${service}`);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
try {
|
|
85
|
+
await executeCommand(host, `pkill -f ${service}`, {
|
|
86
|
+
logger,
|
|
87
|
+
sshPath,
|
|
88
|
+
});
|
|
89
|
+
logger.success(`Killed process: ${service}`);
|
|
90
|
+
} catch (err2) {
|
|
91
|
+
logger.warn(`Failed to stop ${service}: ${err2.message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = {
|
|
99
|
+
executeCommand,
|
|
100
|
+
executeCommandCapture,
|
|
101
|
+
checkConnection,
|
|
102
|
+
stopServices,
|
|
103
|
+
};
|