@surmrf/icloud-surge-sync 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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 iCloud-Surge-Sync contributors
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # iCloud Surge Sync
2
+
3
+ One-way distribution for Surge configuration files. A primary device watches an iCloud-backed folder and publishes
4
+ changes to Git. Other devices periodically pull the Git repository as read-only subscribers.
5
+
6
+ ```text
7
+ iCloud folder -> primary push device -> Git remote -> pull devices
8
+ ```
9
+
10
+ The project uses the Git credentials already configured on each machine. It does not read or store access tokens.
11
+
12
+ ## Install
13
+
14
+ Node.js 18.17 or newer is required.
15
+
16
+ ```bash
17
+ npm install --global @surmrf/icloud-surge-sync
18
+ ```
19
+
20
+ Until the first npm release, install from a checkout with:
21
+
22
+ ```bash
23
+ npm install --global .
24
+ ```
25
+
26
+ ## Configure a device
27
+
28
+ Interactive setup:
29
+
30
+ ```bash
31
+ icss init --role push
32
+ ```
33
+
34
+ The `push` role is the primary publisher. Configure only one primary device unless you are prepared to resolve Git
35
+ push races manually.
36
+
37
+ ```bash
38
+ icss init --role pull
39
+ ```
40
+
41
+ The `pull` role is a read-only subscriber and does not require an iCloud folder.
42
+
43
+ Non-interactive examples:
44
+
45
+ ```bash
46
+ icss init \
47
+ --role push \
48
+ --icloud-folder "/absolute/path/to/icloud/surge" \
49
+ --git-folder "/absolute/path/to/local/repository" \
50
+ --remote "git@github.com:owner/private-config.git"
51
+
52
+ icss init \
53
+ --role pull \
54
+ --git-folder "/absolute/path/to/local/repository" \
55
+ --remote "git@github.com:owner/private-config.git"
56
+ ```
57
+
58
+ `--remote` is only required when the local Git folder has not already been cloned.
59
+
60
+ Default configuration locations:
61
+
62
+ - macOS: `~/Library/Application Support/iCloud-Surge-Sync/config.json`
63
+ - Linux: `~/.config/icloud-surge-sync/config.json`
64
+
65
+ Use `--config /absolute/path/config.json` to override the location.
66
+
67
+ ## Verify and run
68
+
69
+ ```bash
70
+ icss doctor
71
+ icss run
72
+ ```
73
+
74
+ `doctor` validates the selected role, folders, Git repository, `origin`, and existing Git authentication. SSH keys or
75
+ HTTPS credentials must be configured with Git before installing the background service.
76
+
77
+ ## Background service
78
+
79
+ macOS uses a per-user LaunchAgent. Linux uses a systemd user service.
80
+
81
+ ```bash
82
+ icss daemon install
83
+ icss status
84
+ icss logs
85
+ icss daemon restart
86
+ icss daemon uninstall
87
+ ```
88
+
89
+ Configuration and logs are retained during normal uninstall. Remove them explicitly with:
90
+
91
+ ```bash
92
+ icss daemon uninstall --purge
93
+ ```
94
+
95
+ On a headless Linux host, user lingering may need to be enabled separately so the service can start without an
96
+ interactive login.
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ pnpm install
102
+ pnpm format
103
+ pnpm check
104
+ pnpm test
105
+ pnpm verify
106
+ ```
107
+
108
+ Biome is the single formatter and linter for JavaScript and JSON. Use `pnpm check:fix` to apply formatting, import
109
+ organization, and safe lint fixes together.
110
+
111
+ For backwards-compatible source execution, `pnpm push` and `pnpm pull` can use a project-root `config.json` based on
112
+ `config.json.example`.
package/bin/cli.js ADDED
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const readline = require('node:readline/promises');
7
+ const simpleGit = require('simple-git');
8
+ const { stdin, stdout } = require('node:process');
9
+ const packageJson = require('../package.json');
10
+ const { loadConfig, resolveConfigPath, saveConfig } = require('../src/config');
11
+ const { daemonStatus, followLogs, installDaemon, restartDaemon, uninstallDaemon } = require('../src/daemon');
12
+ const { runPull } = require('../src/pull');
13
+ const { runPush } = require('../src/push');
14
+ const {
15
+ isInside,
16
+ validateForPull,
17
+ validateForPush,
18
+ validateGitFolder,
19
+ validateIcloudFolder,
20
+ } = require('../src/utils/config-validator');
21
+
22
+ const BOOLEAN_FLAGS = new Set(['force', 'purge', 'help', 'version']);
23
+
24
+ function camelCase(value) {
25
+ return value.replace(/-([a-z])/g, (_, character) => character.toUpperCase());
26
+ }
27
+
28
+ function parseArguments(argv) {
29
+ const flags = {};
30
+ const positionals = [];
31
+ for (let index = 0; index < argv.length; index += 1) {
32
+ const token = argv[index];
33
+ if (!token.startsWith('--')) {
34
+ positionals.push(token);
35
+ continue;
36
+ }
37
+
38
+ const [rawName, inlineValue] = token.slice(2).split(/=(.*)/s, 2);
39
+ const name = camelCase(rawName);
40
+ if (inlineValue !== undefined) {
41
+ flags[name] = inlineValue;
42
+ } else if (BOOLEAN_FLAGS.has(name)) {
43
+ flags[name] = true;
44
+ } else {
45
+ index += 1;
46
+ if (index >= argv.length) {
47
+ throw new Error(`Missing value for --${rawName}`);
48
+ }
49
+ flags[name] = argv[index];
50
+ }
51
+ }
52
+ return { flags, positionals };
53
+ }
54
+
55
+ function printHelp() {
56
+ console.log(`iCloud Surge Sync ${packageJson.version}
57
+
58
+ Usage:
59
+ icss init --role <push|pull> [options]
60
+ icss run [push|pull] [--config <path>]
61
+ icss doctor [--config <path>]
62
+ icss status [--config <path>]
63
+ icss logs [--config <path>]
64
+ icss daemon <install|uninstall|restart|status> [options]
65
+
66
+ Init options:
67
+ --role <push|pull> Device role; push is the primary publisher
68
+ --icloud-folder <path> Source folder, required for push
69
+ --git-folder <path> Local Git working tree
70
+ --remote <url> Clone URL when git-folder is not a repository
71
+ --branch <name> Branch name, defaults to the repository default
72
+ --poll-interval <ms> Pull interval, defaults to 10000
73
+ --config <path> Override the platform config path
74
+ --force Replace an existing configuration
75
+
76
+ Daemon options:
77
+ --purge With uninstall, also remove config and logs
78
+ `);
79
+ }
80
+
81
+ async function ask(question, defaultValue) {
82
+ const interface_ = readline.createInterface({ input: stdin, output: stdout });
83
+ try {
84
+ const suffix = defaultValue ? ` [${defaultValue}]` : '';
85
+ const answer = (await interface_.question(`${question}${suffix}: `)).trim();
86
+ return answer || defaultValue;
87
+ } finally {
88
+ interface_.close();
89
+ }
90
+ }
91
+
92
+ async function valueOrPrompt(value, question, defaultValue) {
93
+ if (value) {
94
+ return value;
95
+ }
96
+ if (!stdin.isTTY) {
97
+ return defaultValue;
98
+ }
99
+ return ask(question, defaultValue);
100
+ }
101
+
102
+ async function isGitRepository(folder) {
103
+ if (!fs.existsSync(folder)) {
104
+ return false;
105
+ }
106
+ try {
107
+ return await simpleGit(folder).checkIsRepo();
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ async function prepareGitFolder(gitFolder, remote, branch) {
114
+ if (await isGitRepository(gitFolder)) {
115
+ const git = simpleGit(gitFolder);
116
+ const remotes = await git.getRemotes();
117
+ if (remote && !remotes.some((item) => item.name === 'origin')) {
118
+ await git.addRemote('origin', remote);
119
+ }
120
+ return;
121
+ }
122
+
123
+ if (!remote) {
124
+ throw new Error('gitFolder is not a Git repository; provide --remote so it can be cloned');
125
+ }
126
+ if (fs.existsSync(gitFolder) && fs.readdirSync(gitFolder).length > 0) {
127
+ throw new Error(`Cannot clone into a non-empty directory: ${gitFolder}`);
128
+ }
129
+ fs.mkdirSync(path.dirname(gitFolder), { recursive: true });
130
+ const cloneOptions = branch ? ['--branch', branch] : [];
131
+ await simpleGit().clone(remote, gitFolder, cloneOptions);
132
+ }
133
+
134
+ async function initialize(flags) {
135
+ const configPath = resolveConfigPath(flags.config);
136
+ if (fs.existsSync(configPath) && !flags.force) {
137
+ const replacement = stdin.isTTY && (await ask(`Configuration exists at ${configPath}. Replace it?`, 'no'));
138
+ if (replacement?.toLowerCase() !== 'yes') {
139
+ throw new Error('Configuration was not changed; use --force to replace it');
140
+ }
141
+ }
142
+
143
+ const role = (await valueOrPrompt(flags.role, 'Device role (push or pull)', 'push')).toLowerCase();
144
+ if (!['push', 'pull'].includes(role)) {
145
+ throw new Error('Role must be "push" or "pull"');
146
+ }
147
+
148
+ const gitFolderInput = await valueOrPrompt(flags.gitFolder, 'Local Git folder');
149
+ if (!gitFolderInput) {
150
+ throw new Error('gitFolder is required');
151
+ }
152
+ const gitFolder = path.resolve(gitFolderInput.replace(/^~(?=$|[/\\])/, os.homedir()));
153
+ let icloudFolder;
154
+ if (role === 'push') {
155
+ const sourceInput = await valueOrPrompt(flags.icloudFolder, 'iCloud source folder');
156
+ if (!sourceInput) {
157
+ throw new Error('icloudFolder is required for the primary push device');
158
+ }
159
+ icloudFolder = path.resolve(sourceInput.replace(/^~(?=$|[/\\])/, os.homedir()));
160
+ validateIcloudFolder(icloudFolder);
161
+ }
162
+
163
+ if (role === 'push' && (isInside(icloudFolder, gitFolder) || isInside(gitFolder, icloudFolder))) {
164
+ throw new Error('icloudFolder and gitFolder must not be the same directory or nested inside each other');
165
+ }
166
+
167
+ let remote = flags.remote;
168
+ if (!(await isGitRepository(gitFolder))) {
169
+ remote = await valueOrPrompt(remote, 'Git remote URL');
170
+ }
171
+ await prepareGitFolder(gitFolder, remote, flags.branch);
172
+ validateGitFolder(gitFolder);
173
+
174
+ const config = {
175
+ role,
176
+ ...(role === 'push' ? { icloudFolder } : {}),
177
+ gitFolder,
178
+ ...(role === 'pull' ? { pollInterval: Number(flags.pollInterval || 10000) } : {}),
179
+ };
180
+ if (role === 'pull' && (!Number.isFinite(config.pollInterval) || config.pollInterval < 1000)) {
181
+ throw new Error('pollInterval must be a number of at least 1000 milliseconds');
182
+ }
183
+
184
+ const savedPath = saveConfig(config, configPath);
185
+ console.log(`Configured this device as ${role === 'push' ? 'the primary push device' : 'a pull device'}.`);
186
+ console.log(`Configuration: ${savedPath}`);
187
+ console.log('Next: icss doctor && icss daemon install');
188
+ }
189
+
190
+ function getConfiguredRole(configPath, requestedRole) {
191
+ const { config } = loadConfig(configPath);
192
+ const role = requestedRole || config.role;
193
+ if (!['push', 'pull'].includes(role)) {
194
+ throw new Error('No valid device role is configured; run icss init --role push|pull');
195
+ }
196
+ if (requestedRole && config.role && requestedRole !== config.role) {
197
+ throw new Error(`This device is configured as "${config.role}", not "${requestedRole}"`);
198
+ }
199
+ return role;
200
+ }
201
+
202
+ async function runConfigured(configPath, requestedRole) {
203
+ const role = getConfiguredRole(configPath, requestedRole);
204
+ if (role === 'push') {
205
+ await runPush({ configPath });
206
+ } else {
207
+ await runPull({ configPath });
208
+ }
209
+ }
210
+
211
+ async function doctor(configPath) {
212
+ const role = getConfiguredRole(configPath);
213
+ const config = role === 'push' ? validateForPush(configPath) : validateForPull(configPath);
214
+ const git = simpleGit({ baseDir: config.gitFolder, timeout: { block: 60 * 1000 } }).env({
215
+ ...process.env,
216
+ GIT_TERMINAL_PROMPT: '0',
217
+ });
218
+ const remotes = await git.getRemotes(true);
219
+ const origin = remotes.find((remote) => remote.name === 'origin');
220
+ if (!origin) {
221
+ throw new Error('Git remote "origin" is not configured');
222
+ }
223
+ await git.listRemote(['--heads', 'origin']);
224
+
225
+ console.log(`✓ configuration: ${config.configPath}`);
226
+ console.log(`✓ role: ${role}${role === 'push' ? ' (primary device)' : ''}`);
227
+ if (role === 'push') {
228
+ console.log('✓ iCloud folder is readable');
229
+ }
230
+ console.log('✓ Git folder is writable');
231
+ console.log('✓ origin is configured and reachable with existing Git credentials');
232
+ }
233
+
234
+ function validatedConfig(configPath) {
235
+ const role = getConfiguredRole(configPath);
236
+ return role === 'push' ? validateForPush(configPath) : validateForPull(configPath);
237
+ }
238
+
239
+ async function daemonCommand(action, flags) {
240
+ const configPath = resolveConfigPath(flags.config);
241
+ if (action === 'uninstall') {
242
+ uninstallDaemon({ configPath, purge: Boolean(flags.purge) });
243
+ console.log(`Daemon uninstalled${flags.purge ? '; configuration and logs removed' : ''}.`);
244
+ return;
245
+ }
246
+
247
+ if (action === 'status') {
248
+ const status = daemonStatus({ configPath });
249
+ console.log(`installed: ${status.installed ? 'yes' : 'no'}`);
250
+ console.log(`running: ${status.running ? 'yes' : 'no'}`);
251
+ return;
252
+ }
253
+
254
+ if (action === 'restart') {
255
+ restartDaemon({ configPath });
256
+ console.log('Daemon restarted.');
257
+ return;
258
+ }
259
+
260
+ if (action === 'install') {
261
+ await doctor(configPath);
262
+ const config = validatedConfig(configPath);
263
+ const paths = installDaemon({ config, configPath: config.configPath });
264
+ console.log(`Daemon installed for role: ${config.role}`);
265
+ console.log(`Service file: ${paths.servicePath}`);
266
+ if (process.platform === 'linux') {
267
+ console.log('For a headless machine, enable user lingering separately if the service must run before login.');
268
+ }
269
+ return;
270
+ }
271
+
272
+ throw new Error('Daemon action must be install, uninstall, restart, or status');
273
+ }
274
+
275
+ async function main(argv = process.argv.slice(2)) {
276
+ const { flags, positionals } = parseArguments(argv);
277
+ const [command, subcommand] = positionals;
278
+ if (command === 'version' || flags.version) {
279
+ console.log(packageJson.version);
280
+ return;
281
+ }
282
+ if (!command || command === 'help' || flags.help) {
283
+ printHelp();
284
+ return;
285
+ }
286
+ if (command === 'init') {
287
+ await initialize(flags);
288
+ return;
289
+ }
290
+ if (command === 'run') {
291
+ await runConfigured(flags.config, subcommand);
292
+ return;
293
+ }
294
+ if (command === 'doctor') {
295
+ await doctor(flags.config);
296
+ return;
297
+ }
298
+ if (command === 'daemon') {
299
+ await daemonCommand(subcommand, flags);
300
+ return;
301
+ }
302
+ if (command === 'status') {
303
+ await daemonCommand('status', flags);
304
+ return;
305
+ }
306
+ if (command === 'logs') {
307
+ await followLogs({ configPath: resolveConfigPath(flags.config) });
308
+ return;
309
+ }
310
+ throw new Error(`Unknown command: ${command}`);
311
+ }
312
+
313
+ if (require.main === module) {
314
+ main().catch((error) => {
315
+ console.error(`Error: ${error.message}`);
316
+ process.exitCode = 1;
317
+ });
318
+ }
319
+
320
+ module.exports = { main, parseArguments };
@@ -0,0 +1,5 @@
1
+ {
2
+ "role": "push",
3
+ "icloudFolder": "/Users/username/Library/Mobile Documents/com~apple~CloudDocs/MyFiles",
4
+ "gitFolder": "/Users/username/Workspaces/my-sync-repo"
5
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@surmrf/icloud-surge-sync",
3
+ "version": "1.0.0",
4
+ "description": "One-way Surge configuration distribution from iCloud through Git.",
5
+ "bin": {
6
+ "icloud-surge-sync": "bin/cli.js",
7
+ "icss": "bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "config.json.example",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "push": "node bin/cli.js run push",
18
+ "pull": "node bin/cli.js run pull",
19
+ "test": "node --test",
20
+ "format": "biome format --write .",
21
+ "lint": "biome lint .",
22
+ "check": "biome check .",
23
+ "check:fix": "biome check --write .",
24
+ "verify": "biome ci . && node --test",
25
+ "prepublishOnly": "npm run verify"
26
+ },
27
+ "keywords": [
28
+ "icloud",
29
+ "surge",
30
+ "git",
31
+ "sync",
32
+ "backup"
33
+ ],
34
+ "author": "",
35
+ "license": "ISC",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/anyforker/iCloud-Surge-Sync.git"
39
+ },
40
+ "homepage": "https://github.com/anyforker/iCloud-Surge-Sync#readme",
41
+ "bugs": {
42
+ "url": "https://github.com/anyforker/iCloud-Surge-Sync/issues"
43
+ },
44
+ "engines": {
45
+ "node": ">=18.17"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public",
49
+ "registry": "https://registry.npmjs.org/"
50
+ },
51
+ "packageManager": "pnpm@10.8.0",
52
+ "dependencies": {
53
+ "chokidar": "^4.0.3",
54
+ "fs-extra": "^11.3.0",
55
+ "simple-git": "^3.36.0"
56
+ },
57
+ "devDependencies": {
58
+ "@biomejs/biome": "2.5.6"
59
+ }
60
+ }
package/src/config.js ADDED
@@ -0,0 +1,111 @@
1
+ const fs = require('node:fs');
2
+ const os = require('node:os');
3
+ const path = require('node:path');
4
+
5
+ const APP_DIRECTORY = 'iCloud-Surge-Sync';
6
+ const CONFIG_ENV = 'ICLOUD_SURGE_SYNC_CONFIG';
7
+
8
+ function expandHome(value, homeDirectory = os.homedir()) {
9
+ if (value === '~') {
10
+ return homeDirectory;
11
+ }
12
+ if (value.startsWith(`~${path.sep}`) || value.startsWith('~/')) {
13
+ return path.join(homeDirectory, value.slice(2));
14
+ }
15
+ return value;
16
+ }
17
+
18
+ function getDefaultConfigPath(options = {}) {
19
+ const platform = options.platform || process.platform;
20
+ const environment = options.environment || process.env;
21
+ const homeDirectory = options.homeDirectory || os.homedir();
22
+
23
+ if (platform === 'darwin') {
24
+ return path.join(homeDirectory, 'Library', 'Application Support', APP_DIRECTORY, 'config.json');
25
+ }
26
+ if (platform === 'win32') {
27
+ const appData = environment.APPDATA || path.join(homeDirectory, 'AppData', 'Roaming');
28
+ return path.join(appData, APP_DIRECTORY, 'config.json');
29
+ }
30
+
31
+ const configHome = environment.XDG_CONFIG_HOME || path.join(homeDirectory, '.config');
32
+ return path.join(configHome, 'icloud-surge-sync', 'config.json');
33
+ }
34
+
35
+ function resolveConfigPath(explicitPath, options = {}) {
36
+ const environment = options.environment || process.env;
37
+ const selected = explicitPath || environment[CONFIG_ENV] || getDefaultConfigPath(options);
38
+ return path.resolve(expandHome(selected, options.homeDirectory));
39
+ }
40
+
41
+ function findConfigPath(explicitPath, options = {}) {
42
+ const resolved = resolveConfigPath(explicitPath, options);
43
+ if (explicitPath || (options.environment || process.env)[CONFIG_ENV] || fs.existsSync(resolved)) {
44
+ return resolved;
45
+ }
46
+
47
+ // Keep direct execution from a source checkout backwards-compatible.
48
+ const legacyPath = path.resolve(__dirname, '../config.json');
49
+ return fs.existsSync(legacyPath) ? legacyPath : resolved;
50
+ }
51
+
52
+ function normalizeConfig(config) {
53
+ const normalized = { ...config };
54
+ if (typeof normalized.icloudFolder === 'string') {
55
+ normalized.icloudFolder = path.resolve(expandHome(normalized.icloudFolder));
56
+ }
57
+ if (typeof normalized.gitFolder === 'string') {
58
+ normalized.gitFolder = path.resolve(expandHome(normalized.gitFolder));
59
+ }
60
+ return normalized;
61
+ }
62
+
63
+ function loadConfig(explicitPath, options = {}) {
64
+ const configPath = findConfigPath(explicitPath, options);
65
+ if (!fs.existsSync(configPath)) {
66
+ const error = new Error(`Configuration file not found: ${configPath}`);
67
+ error.code = 'CONFIG_NOT_FOUND';
68
+ throw error;
69
+ }
70
+
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
74
+ } catch (error) {
75
+ error.message = `Failed to parse configuration ${configPath}: ${error.message}`;
76
+ error.code = 'CONFIG_INVALID_JSON';
77
+ throw error;
78
+ }
79
+
80
+ return { config: normalizeConfig(parsed), configPath };
81
+ }
82
+
83
+ function saveConfig(config, explicitPath, options = {}) {
84
+ const configPath = resolveConfigPath(explicitPath, options);
85
+ fs.mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 });
86
+ const temporaryPath = `${configPath}.${process.pid}.tmp`;
87
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(normalizeConfig(config), null, 2)}\n`, { mode: 0o600 });
88
+ fs.renameSync(temporaryPath, configPath);
89
+ try {
90
+ fs.chmodSync(configPath, 0o600);
91
+ } catch {
92
+ // Windows does not implement POSIX file modes.
93
+ }
94
+ return configPath;
95
+ }
96
+
97
+ function getRuntimeDirectory(configPath) {
98
+ return path.dirname(configPath);
99
+ }
100
+
101
+ module.exports = {
102
+ CONFIG_ENV,
103
+ expandHome,
104
+ findConfigPath,
105
+ getDefaultConfigPath,
106
+ getRuntimeDirectory,
107
+ loadConfig,
108
+ normalizeConfig,
109
+ resolveConfigPath,
110
+ saveConfig,
111
+ };
package/src/daemon.js ADDED
@@ -0,0 +1,225 @@
1
+ const fs = require('node:fs');
2
+ const os = require('node:os');
3
+ const path = require('node:path');
4
+ const { spawnSync, spawn } = require('node:child_process');
5
+ const { getRuntimeDirectory } = require('./config');
6
+
7
+ const SERVICE_LABEL = 'io.github.anyforker.icloud-surge-sync';
8
+ const SYSTEMD_SERVICE = 'icloud-surge-sync.service';
9
+
10
+ function escapeXml(value) {
11
+ return String(value)
12
+ .replaceAll('&', '&amp;')
13
+ .replaceAll('<', '&lt;')
14
+ .replaceAll('>', '&gt;')
15
+ .replaceAll('"', '&quot;')
16
+ .replaceAll("'", '&apos;');
17
+ }
18
+
19
+ function quoteSystemd(value) {
20
+ const escaped = String(value)
21
+ .replaceAll('\\', '\\\\')
22
+ .replaceAll('"', '\\"')
23
+ .replaceAll('$', '$$')
24
+ .replaceAll('%', '%%');
25
+ return `"${escaped}"`;
26
+ }
27
+
28
+ function getExecutableArguments(configPath) {
29
+ const cliPath = path.resolve(__dirname, '../bin/cli.js');
30
+ return [process.execPath, cliPath, 'run', '--config', configPath];
31
+ }
32
+
33
+ function buildLaunchAgent({ configPath, gitFolder, logPath, environmentPath = process.env.PATH }) {
34
+ const argumentsXml = getExecutableArguments(configPath)
35
+ .map((argument) => ` <string>${escapeXml(argument)}</string>`)
36
+ .join('\n');
37
+ const pathValue = environmentPath || '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin';
38
+
39
+ return `<?xml version="1.0" encoding="UTF-8"?>
40
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
41
+ <plist version="1.0">
42
+ <dict>
43
+ <key>Label</key>
44
+ <string>${SERVICE_LABEL}</string>
45
+ <key>ProgramArguments</key>
46
+ <array>
47
+ ${argumentsXml}
48
+ </array>
49
+ <key>WorkingDirectory</key>
50
+ <string>${escapeXml(gitFolder)}</string>
51
+ <key>EnvironmentVariables</key>
52
+ <dict>
53
+ <key>PATH</key>
54
+ <string>${escapeXml(pathValue)}</string>
55
+ <key>GIT_TERMINAL_PROMPT</key>
56
+ <string>0</string>
57
+ </dict>
58
+ <key>RunAtLoad</key>
59
+ <true/>
60
+ <key>KeepAlive</key>
61
+ <true/>
62
+ <key>ThrottleInterval</key>
63
+ <integer>10</integer>
64
+ <key>StandardOutPath</key>
65
+ <string>${escapeXml(logPath)}</string>
66
+ <key>StandardErrorPath</key>
67
+ <string>${escapeXml(logPath)}</string>
68
+ </dict>
69
+ </plist>
70
+ `;
71
+ }
72
+
73
+ function buildSystemdUnit({ configPath, gitFolder }) {
74
+ const command = getExecutableArguments(configPath).map(quoteSystemd).join(' ');
75
+ return `[Unit]
76
+ Description=iCloud Surge Sync
77
+ After=network-online.target
78
+ Wants=network-online.target
79
+
80
+ [Service]
81
+ Type=simple
82
+ ExecStart=${command}
83
+ WorkingDirectory=${quoteSystemd(gitFolder)}
84
+ Environment=GIT_TERMINAL_PROMPT=0
85
+ Restart=on-failure
86
+ RestartSec=10
87
+
88
+ [Install]
89
+ WantedBy=default.target
90
+ `;
91
+ }
92
+
93
+ function run(command, arguments_, options = {}) {
94
+ const result = spawnSync(command, arguments_, {
95
+ encoding: 'utf8',
96
+ stdio: options.inherit ? 'inherit' : 'pipe',
97
+ });
98
+ if (result.error) {
99
+ throw result.error;
100
+ }
101
+ if (result.status !== 0 && !options.allowFailure) {
102
+ const details = (result.stderr || result.stdout || '').trim();
103
+ throw new Error(`${command} failed${details ? `: ${details}` : ''}`);
104
+ }
105
+ return result;
106
+ }
107
+
108
+ function getDaemonPaths(configPath, platform = process.platform) {
109
+ const runtimeDirectory = getRuntimeDirectory(configPath);
110
+ const logDirectory = path.join(runtimeDirectory, 'logs');
111
+ if (platform === 'darwin') {
112
+ return {
113
+ servicePath: path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`),
114
+ logDirectory,
115
+ logPath: path.join(logDirectory, 'daemon.log'),
116
+ };
117
+ }
118
+ if (platform === 'linux') {
119
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
120
+ return {
121
+ servicePath: path.join(configHome, 'systemd', 'user', SYSTEMD_SERVICE),
122
+ logDirectory,
123
+ logPath: null,
124
+ };
125
+ }
126
+ throw new Error(`Daemon management is not supported on ${platform}`);
127
+ }
128
+
129
+ function installDaemon({ config, configPath, platform = process.platform }) {
130
+ const paths = getDaemonPaths(configPath, platform);
131
+ fs.mkdirSync(path.dirname(paths.servicePath), { recursive: true });
132
+ fs.mkdirSync(paths.logDirectory, { recursive: true, mode: 0o700 });
133
+
134
+ if (platform === 'darwin') {
135
+ const domain = `gui/${process.getuid()}`;
136
+ run('launchctl', ['bootout', `${domain}/${SERVICE_LABEL}`], { allowFailure: true });
137
+ fs.writeFileSync(
138
+ paths.servicePath,
139
+ buildLaunchAgent({
140
+ configPath,
141
+ gitFolder: config.gitFolder,
142
+ logPath: paths.logPath,
143
+ }),
144
+ { mode: 0o600 },
145
+ );
146
+ run('launchctl', ['bootstrap', domain, paths.servicePath]);
147
+ return paths;
148
+ }
149
+
150
+ fs.writeFileSync(paths.servicePath, buildSystemdUnit({ configPath, gitFolder: config.gitFolder }), { mode: 0o644 });
151
+ run('systemctl', ['--user', 'daemon-reload']);
152
+ run('systemctl', ['--user', 'enable', '--now', SYSTEMD_SERVICE]);
153
+ return paths;
154
+ }
155
+
156
+ function uninstallDaemon({ configPath, platform = process.platform, purge = false }) {
157
+ const paths = getDaemonPaths(configPath, platform);
158
+ if (platform === 'darwin') {
159
+ const domain = `gui/${process.getuid()}`;
160
+ run('launchctl', ['bootout', `${domain}/${SERVICE_LABEL}`], { allowFailure: true });
161
+ } else {
162
+ run('systemctl', ['--user', 'disable', '--now', SYSTEMD_SERVICE], { allowFailure: true });
163
+ }
164
+
165
+ fs.rmSync(paths.servicePath, { force: true });
166
+ if (platform === 'linux') {
167
+ run('systemctl', ['--user', 'daemon-reload'], { allowFailure: true });
168
+ }
169
+ if (purge) {
170
+ fs.rmSync(configPath, { force: true });
171
+ fs.rmSync(paths.logDirectory, { recursive: true, force: true });
172
+ }
173
+ return paths;
174
+ }
175
+
176
+ function daemonStatus({ configPath, platform = process.platform }) {
177
+ const paths = getDaemonPaths(configPath, platform);
178
+ if (!fs.existsSync(paths.servicePath)) {
179
+ return { installed: false, running: false, details: 'Service file is not installed.' };
180
+ }
181
+
182
+ if (platform === 'darwin') {
183
+ const target = `gui/${process.getuid()}/${SERVICE_LABEL}`;
184
+ const result = run('launchctl', ['print', target], { allowFailure: true });
185
+ return { installed: true, running: result.status === 0, details: (result.stdout || result.stderr).trim(), paths };
186
+ }
187
+
188
+ const result = run('systemctl', ['--user', 'is-active', SYSTEMD_SERVICE], { allowFailure: true });
189
+ return { installed: true, running: result.stdout.trim() === 'active', details: result.stdout.trim(), paths };
190
+ }
191
+
192
+ function restartDaemon({ platform = process.platform }) {
193
+ if (platform === 'darwin') {
194
+ run('launchctl', ['kickstart', '-k', `gui/${process.getuid()}/${SERVICE_LABEL}`]);
195
+ } else if (platform === 'linux') {
196
+ run('systemctl', ['--user', 'restart', SYSTEMD_SERVICE]);
197
+ } else {
198
+ throw new Error(`Daemon management is not supported on ${platform}`);
199
+ }
200
+ }
201
+
202
+ function followLogs({ configPath, platform = process.platform }) {
203
+ const paths = getDaemonPaths(configPath, platform);
204
+ const command = platform === 'darwin' ? 'tail' : 'journalctl';
205
+ const arguments_ =
206
+ platform === 'darwin' ? ['-n', '100', '-f', paths.logPath] : ['--user', '-u', SYSTEMD_SERVICE, '-n', '100', '-f'];
207
+ const child = spawn(command, arguments_, { stdio: 'inherit' });
208
+ return new Promise((resolve, reject) => {
209
+ child.once('error', reject);
210
+ child.once('exit', (code) => resolve(code || 0));
211
+ });
212
+ }
213
+
214
+ module.exports = {
215
+ SERVICE_LABEL,
216
+ SYSTEMD_SERVICE,
217
+ buildLaunchAgent,
218
+ buildSystemdUnit,
219
+ daemonStatus,
220
+ followLogs,
221
+ getDaemonPaths,
222
+ installDaemon,
223
+ restartDaemon,
224
+ uninstallDaemon,
225
+ };
package/src/pull.js ADDED
@@ -0,0 +1,75 @@
1
+ const simpleGit = require('simple-git');
2
+ const logger = require('./utils/logger');
3
+ const { validateForPull } = require('./utils/config-validator');
4
+
5
+ function createGit(gitFolder) {
6
+ return simpleGit({
7
+ baseDir: gitFolder,
8
+ maxConcurrentProcesses: 1,
9
+ timeout: { block: 60 * 1000 },
10
+ }).env({
11
+ ...process.env,
12
+ GIT_TERMINAL_PROMPT: '0',
13
+ });
14
+ }
15
+
16
+ async function runPull(options = {}) {
17
+ const config = options.config || validateForPull(options.configPath);
18
+ const git = createGit(config.gitFolder);
19
+ const pollInterval = options.pollInterval || config.pollInterval || 10 * 1000;
20
+ let pollTimer = null;
21
+ let stopped = false;
22
+
23
+ const checkAndPull = async () => {
24
+ try {
25
+ logger.log('Checking for updates...');
26
+ await git.fetch();
27
+ const status = await git.status();
28
+ if (status.behind > 0) {
29
+ logger.log(`Repo is behind by ${status.behind} commits. Pulling...`);
30
+ await git.pull();
31
+ logger.log('Pull done.');
32
+ } else {
33
+ logger.log('Up to date. No action needed.');
34
+ }
35
+ } catch (error) {
36
+ logger.error('Fetch/pull failed:', error.message);
37
+ logger.log('Will retry in the next check cycle...');
38
+ }
39
+ };
40
+
41
+ const poll = async () => {
42
+ await checkAndPull();
43
+ if (!stopped) {
44
+ pollTimer = setTimeout(poll, pollInterval);
45
+ }
46
+ };
47
+
48
+ const stop = () => {
49
+ stopped = true;
50
+ clearTimeout(pollTimer);
51
+ logger.log('Pull service stopped.');
52
+ };
53
+
54
+ if (options.handleSignals !== false) {
55
+ for (const signal of ['SIGINT', 'SIGTERM']) {
56
+ process.once(signal, () => {
57
+ stop();
58
+ process.exit(0);
59
+ });
60
+ }
61
+ }
62
+
63
+ logger.log(`Pull device is tracking: ${config.gitFolder}`);
64
+ poll();
65
+ return { stop, checkAndPull };
66
+ }
67
+
68
+ if (require.main === module) {
69
+ runPull().catch((error) => {
70
+ logger.error(error.message);
71
+ process.exitCode = 1;
72
+ });
73
+ }
74
+
75
+ module.exports = { runPull };
package/src/push.js ADDED
@@ -0,0 +1,152 @@
1
+ const chokidar = require('chokidar');
2
+ const fs = require('fs-extra');
3
+ const path = require('node:path');
4
+ const simpleGit = require('simple-git');
5
+ const logger = require('./utils/logger');
6
+ const { validateForPush } = require('./utils/config-validator');
7
+
8
+ function createGit(gitFolder) {
9
+ return simpleGit({
10
+ baseDir: gitFolder,
11
+ maxConcurrentProcesses: 1,
12
+ timeout: { block: 60 * 1000 },
13
+ }).env({
14
+ ...process.env,
15
+ GIT_TERMINAL_PROMPT: '0',
16
+ });
17
+ }
18
+
19
+ async function runPush(options = {}) {
20
+ const config = options.config || validateForPush(options.configPath);
21
+ const icloudFolder = config.icloudFolder;
22
+ const gitFolder = config.gitFolder;
23
+ const git = createGit(gitFolder);
24
+
25
+ const copyChangedFile = async (filePath) => {
26
+ const relative = path.relative(icloudFolder, filePath);
27
+ const destination = path.join(gitFolder, relative);
28
+ try {
29
+ await fs.ensureDir(path.dirname(destination));
30
+ await fs.copy(filePath, destination);
31
+ logger.log(`Copied: ${relative}`);
32
+ } catch (error) {
33
+ if (error.code === 'ENOENT') {
34
+ logger.error(`Source file not found: ${relative}`);
35
+ } else if (error.code === 'EACCES') {
36
+ logger.error(`Permission denied when copying: ${relative}`);
37
+ } else if (error.code === 'ENOSPC') {
38
+ logger.error(`No space left on device when copying: ${relative}`);
39
+ } else {
40
+ logger.error(`Copy failed: ${relative}`, error.message);
41
+ }
42
+ }
43
+ };
44
+
45
+ const removeDeletedFile = async (filePath) => {
46
+ const relative = path.relative(icloudFolder, filePath);
47
+ const destination = path.join(gitFolder, relative);
48
+ try {
49
+ await fs.remove(destination);
50
+ logger.log(`Deleted: ${relative}`);
51
+ } catch (error) {
52
+ if (error.code === 'ENOENT') {
53
+ logger.log(`File already deleted: ${relative}`);
54
+ } else if (error.code === 'EACCES') {
55
+ logger.error(`Permission denied when deleting: ${relative}`);
56
+ } else {
57
+ logger.error(`Delete failed: ${relative}`, error.message);
58
+ }
59
+ }
60
+ };
61
+
62
+ let pushTimer = null;
63
+ let pushInProgress = false;
64
+ let pushPending = false;
65
+
66
+ const scheduleCommitAndPush = () => {
67
+ pushPending = true;
68
+ clearTimeout(pushTimer);
69
+ pushTimer = setTimeout(commitAndPush, options.debounceMs || 5000);
70
+ };
71
+
72
+ const commitAndPush = async () => {
73
+ if (pushInProgress) {
74
+ return;
75
+ }
76
+
77
+ pushInProgress = true;
78
+ pushPending = false;
79
+
80
+ try {
81
+ await git.add('.');
82
+ const status = await git.status();
83
+ if (status.staged.length === 0) {
84
+ logger.log('No changes to commit');
85
+ return;
86
+ }
87
+
88
+ logger.log(`Committing ${status.staged.length} file(s)...`);
89
+ await git.commit(`Auto-sync: ${new Date().toISOString()}`);
90
+ logger.log('Pushing to remote...');
91
+ await git.push();
92
+ logger.log('Git push done.');
93
+ } catch (error) {
94
+ logger.error('Git operation failed:', error.message);
95
+ } finally {
96
+ pushInProgress = false;
97
+ if (pushPending) {
98
+ scheduleCommitAndPush();
99
+ }
100
+ }
101
+ };
102
+
103
+ const watcher = chokidar.watch(icloudFolder, {
104
+ ignored: /(^|[/\\])\../,
105
+ persistent: true,
106
+ ignoreInitial: false,
107
+ });
108
+
109
+ watcher
110
+ .on('add', async (filePath) => {
111
+ await copyChangedFile(filePath);
112
+ scheduleCommitAndPush();
113
+ })
114
+ .on('change', async (filePath) => {
115
+ await copyChangedFile(filePath);
116
+ scheduleCommitAndPush();
117
+ })
118
+ .on('unlink', async (filePath) => {
119
+ await removeDeletedFile(filePath);
120
+ scheduleCommitAndPush();
121
+ })
122
+ .on('error', (error) => {
123
+ logger.error('Watcher error:', error.message);
124
+ });
125
+
126
+ const stop = async () => {
127
+ clearTimeout(pushTimer);
128
+ await watcher.close();
129
+ logger.log('Push service stopped.');
130
+ };
131
+
132
+ if (options.handleSignals !== false) {
133
+ for (const signal of ['SIGINT', 'SIGTERM']) {
134
+ process.once(signal, async () => {
135
+ await stop();
136
+ process.exit(0);
137
+ });
138
+ }
139
+ }
140
+
141
+ logger.log(`Primary push device is watching: ${icloudFolder}`);
142
+ return { watcher, stop, commitAndPush };
143
+ }
144
+
145
+ if (require.main === module) {
146
+ runPush().catch((error) => {
147
+ logger.error(error.message);
148
+ process.exitCode = 1;
149
+ });
150
+ }
151
+
152
+ module.exports = { runPush };
@@ -0,0 +1,89 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { loadConfig } = require('../config');
4
+ const logger = require('./logger');
5
+
6
+ class ConfigError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = 'ConfigError';
10
+ }
11
+ }
12
+
13
+ function isInside(parent, child) {
14
+ const relative = path.relative(parent, child);
15
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
16
+ }
17
+
18
+ function assertDirectory(folder, fieldName) {
19
+ if (!folder) {
20
+ throw new ConfigError(`${fieldName} is not configured`);
21
+ }
22
+ if (!path.isAbsolute(folder)) {
23
+ throw new ConfigError(`${fieldName} must be an absolute path: ${folder}`);
24
+ }
25
+ if (!fs.existsSync(folder)) {
26
+ throw new ConfigError(`${fieldName} does not exist: ${folder}`);
27
+ }
28
+ if (!fs.statSync(folder).isDirectory()) {
29
+ throw new ConfigError(`${fieldName} is not a directory: ${folder}`);
30
+ }
31
+ }
32
+
33
+ function validateGitFolder(gitFolder) {
34
+ assertDirectory(gitFolder, 'gitFolder');
35
+ const gitMarker = path.join(gitFolder, '.git');
36
+ if (!fs.existsSync(gitMarker)) {
37
+ throw new ConfigError(`gitFolder is not a Git repository: ${gitFolder}`);
38
+ }
39
+ fs.accessSync(gitFolder, fs.constants.R_OK | fs.constants.W_OK);
40
+ logger.log(`Git folder validated: ${gitFolder}`);
41
+ }
42
+
43
+ function validateIcloudFolder(icloudFolder) {
44
+ assertDirectory(icloudFolder, 'icloudFolder');
45
+ fs.accessSync(icloudFolder, fs.constants.R_OK);
46
+ logger.log(`iCloud folder validated: ${icloudFolder}`);
47
+ }
48
+
49
+ function validateRole(config, expectedRole) {
50
+ if (config.role && config.role !== expectedRole) {
51
+ throw new ConfigError(`This device is configured as "${config.role}", not "${expectedRole}"`);
52
+ }
53
+ }
54
+
55
+ function readAndValidate(configPath, expectedRole) {
56
+ const loaded = loadConfig(configPath);
57
+ validateRole(loaded.config, expectedRole);
58
+ validateGitFolder(loaded.config.gitFolder);
59
+
60
+ if (expectedRole === 'push') {
61
+ validateIcloudFolder(loaded.config.icloudFolder);
62
+ const source = path.resolve(loaded.config.icloudFolder);
63
+ const target = path.resolve(loaded.config.gitFolder);
64
+ if (isInside(source, target) || isInside(target, source)) {
65
+ throw new ConfigError('icloudFolder and gitFolder must not be the same directory or nested inside each other');
66
+ }
67
+ }
68
+
69
+ return { ...loaded.config, role: expectedRole, configPath: loaded.configPath };
70
+ }
71
+
72
+ function validateForPull(configPath) {
73
+ logger.log('Validating pull device configuration...');
74
+ return readAndValidate(configPath, 'pull');
75
+ }
76
+
77
+ function validateForPush(configPath) {
78
+ logger.log('Validating primary push device configuration...');
79
+ return readAndValidate(configPath, 'push');
80
+ }
81
+
82
+ module.exports = {
83
+ ConfigError,
84
+ isInside,
85
+ validateForPull,
86
+ validateForPush,
87
+ validateGitFolder,
88
+ validateIcloudFolder,
89
+ };
@@ -0,0 +1,32 @@
1
+ const APP_NAME = 'iCloud-Surge-Sync';
2
+
3
+ function pad(value, length = 2) {
4
+ return String(value).padStart(length, '0');
5
+ }
6
+
7
+ function formatTimestamp(date = new Date()) {
8
+ return `${[date.getFullYear(), pad(date.getMonth() + 1), pad(date.getDate())].join(
9
+ '-',
10
+ )} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
11
+ }
12
+
13
+ function write(level, message, ...args) {
14
+ const line = `${formatTimestamp()} | ${level} | ${APP_NAME} | ${message}`;
15
+ if (level === 'ERROR') {
16
+ console.error(line, ...args);
17
+ } else {
18
+ console.log(line, ...args);
19
+ }
20
+ }
21
+
22
+ const logger = {
23
+ log: (message, ...args) => write('INFO', message, ...args),
24
+ info: (message, ...args) => write('INFO', message, ...args),
25
+ warn: (message, ...args) => write('WARN', message, ...args),
26
+ error: (message, ...args) => write('ERROR', message, ...args),
27
+ success: (message, ...args) => write('SUCCESS', message, ...args),
28
+ start: (message, ...args) => write('START', message, ...args),
29
+ stop: (message, ...args) => write('STOP', message, ...args),
30
+ };
31
+
32
+ module.exports = logger;