parallelclaw 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/CHANGELOG.md +204 -0
- package/HELP.md +600 -0
- package/LICENSE +21 -0
- package/MULTI_MACHINE.md +152 -0
- package/README.md +417 -0
- package/README.ru.md +740 -0
- package/SYNC.md +844 -0
- package/bot/README.md +173 -0
- package/bot/config.js +66 -0
- package/bot/inbox.js +153 -0
- package/bot/index.js +294 -0
- package/bot/nexara.js +61 -0
- package/bot/poll.js +304 -0
- package/bot/search.js +155 -0
- package/bot/telegram.js +96 -0
- package/ingest.js +2712 -0
- package/lib/cli/index.js +1987 -0
- package/lib/config.js +220 -0
- package/lib/db-init.js +158 -0
- package/lib/hook/install.js +268 -0
- package/lib/import-telegram.js +158 -0
- package/lib/ingest-file.js +779 -0
- package/lib/notify-click-action.js +281 -0
- package/lib/openclaw-channel.js +643 -0
- package/lib/parse-cursor.js +172 -0
- package/lib/parse-obsidian.js +256 -0
- package/lib/parse-telegram-html.js +384 -0
- package/lib/parse.js +175 -0
- package/lib/render-markdown.js +0 -0
- package/lib/store-doc/canonicalize.js +116 -0
- package/lib/store-doc/detect.js +209 -0
- package/lib/store-doc/extract-title.js +162 -0
- package/lib/sync/auth.js +80 -0
- package/lib/sync/cert.js +144 -0
- package/lib/sync/cli.js +906 -0
- package/lib/sync/client.js +138 -0
- package/lib/sync/config.js +130 -0
- package/lib/sync/pair.js +145 -0
- package/lib/sync/pull.js +158 -0
- package/lib/sync/push.js +305 -0
- package/lib/sync/replicate.js +335 -0
- package/lib/sync/server.js +224 -0
- package/lib/sync/service.js +726 -0
- package/lib/tasks.js +215 -0
- package/lib/telegram-decisions.js +165 -0
- package/lib/telegram-discovery.js +373 -0
- package/lib/telegram-notify.js +272 -0
- package/lib/telegram-pending.js +200 -0
- package/lib/web/index.js +265 -0
- package/lib/web/routes/conversation.js +193 -0
- package/lib/web/routes/conversations.js +180 -0
- package/lib/web/routes/dashboard.js +175 -0
- package/lib/web/routes/pending.js +277 -0
- package/lib/web/routes/settings.js +226 -0
- package/lib/web/static/style.css +393 -0
- package/lib/web/templates.js +234 -0
- package/package.json +84 -0
- package/server.js +3816 -0
- package/skills/install-memex/README.md +109 -0
- package/skills/install-memex/SKILL.md +342 -0
- package/skills/install-memex/examples.md +294 -0
- package/skills/install-memex-claw/SKILL.md +423 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable service registration for the sync-server (Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Turns `memex-sync sync-server start` (a foreground process) into a managed
|
|
5
|
+
* service that survives reboot and auto-restarts on crash:
|
|
6
|
+
* • macOS → LaunchAgent com.parallelclaw.memex.syncserver
|
|
7
|
+
* • Linux → systemd-user memex-sync-server.service
|
|
8
|
+
*
|
|
9
|
+
* Deliberately SEPARATE from the capture daemon (com.parallelclaw.memex.sync
|
|
10
|
+
* / memex-sync.service). A host can run both: the capture daemon ingests local
|
|
11
|
+
* sources, the sync-server answers remote pull/push. Different jobs, different
|
|
12
|
+
* lifecycles.
|
|
13
|
+
*
|
|
14
|
+
* The bearer token and TLS cert persist on disk (~/.memex/config.json +
|
|
15
|
+
* sync-cert.pem), so a restart reuses the SAME credentials — paired peers
|
|
16
|
+
* keep working without re-pairing. That's the whole point of Phase 2.
|
|
17
|
+
*
|
|
18
|
+
* The unit/plist MUST inject MEMEX_SYNC_EXPERIMENTAL=1, otherwise the
|
|
19
|
+
* sync-server start command refuses to run (experimental gate).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { platform, homedir } from 'node:os';
|
|
23
|
+
import { join, resolve } from 'node:path';
|
|
24
|
+
import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
|
25
|
+
import { execSync } from 'node:child_process';
|
|
26
|
+
|
|
27
|
+
const HOME = homedir();
|
|
28
|
+
const MEMEX_DIR = process.env.MEMEX_DIR || join(HOME, '.memex');
|
|
29
|
+
const DATA = join(MEMEX_DIR, 'data');
|
|
30
|
+
|
|
31
|
+
// Service identity — distinct from the capture daemon.
|
|
32
|
+
const MAC_LABEL = 'com.parallelclaw.memex.syncserver';
|
|
33
|
+
const MAC_PLIST = join(HOME, 'Library', 'LaunchAgents', `${MAC_LABEL}.plist`);
|
|
34
|
+
const LINUX_UNIT = 'memex-sync-server.service';
|
|
35
|
+
const LINUX_DIR = join(HOME, '.config', 'systemd', 'user');
|
|
36
|
+
const LINUX_PATH = join(LINUX_DIR, LINUX_UNIT);
|
|
37
|
+
|
|
38
|
+
const OUT_LOG = join(DATA, 'sync-server.out.log');
|
|
39
|
+
const ERR_LOG = join(DATA, 'sync-server.err.log');
|
|
40
|
+
|
|
41
|
+
// Scheduler identity (Phase 3) — distinct again from both the capture daemon
|
|
42
|
+
// AND the sync-server. This is the client-side timer that runs `sync-run --all`
|
|
43
|
+
// every N minutes. On a hub (VPS) you typically run sync-server; on a spoke
|
|
44
|
+
// (laptop) you run the schedule. A machine can run both.
|
|
45
|
+
const SCHED_MAC_LABEL = 'com.parallelclaw.memex.syncschedule';
|
|
46
|
+
const SCHED_MAC_PLIST = join(HOME, 'Library', 'LaunchAgents', `${SCHED_MAC_LABEL}.plist`);
|
|
47
|
+
const SCHED_LINUX_SERVICE = 'memex-sync-schedule.service';
|
|
48
|
+
const SCHED_LINUX_TIMER = 'memex-sync-schedule.timer';
|
|
49
|
+
const SCHED_SERVICE_PATH = join(LINUX_DIR, SCHED_LINUX_SERVICE);
|
|
50
|
+
const SCHED_TIMER_PATH = join(LINUX_DIR, SCHED_LINUX_TIMER);
|
|
51
|
+
const SCHED_OUT_LOG = join(DATA, 'sync-schedule.out.log');
|
|
52
|
+
const SCHED_ERR_LOG = join(DATA, 'sync-schedule.err.log');
|
|
53
|
+
|
|
54
|
+
// Tunnel keeper (sync-join, v0.13) — a durable forward SSH tunnel from a
|
|
55
|
+
// spoke (laptop) to the hub's loopback sync-server. The OS supervisor
|
|
56
|
+
// (KeepAlive / Restart=always) respawns ssh whenever it exits — sleep/wake,
|
|
57
|
+
// network change, drop — which is what makes the tunnel self-healing.
|
|
58
|
+
const TUNNEL_MAC_LABEL = 'com.parallelclaw.memex.synctunnel';
|
|
59
|
+
const TUNNEL_MAC_PLIST = join(HOME, 'Library', 'LaunchAgents', `${TUNNEL_MAC_LABEL}.plist`);
|
|
60
|
+
const TUNNEL_LINUX_UNIT = 'memex-sync-tunnel.service';
|
|
61
|
+
const TUNNEL_LINUX_PATH = join(LINUX_DIR, TUNNEL_LINUX_UNIT);
|
|
62
|
+
const TUNNEL_SCRIPT = join(MEMEX_DIR, 'sync-tunnel.sh');
|
|
63
|
+
const TUNNEL_OUT_LOG = join(DATA, 'sync-tunnel.out.log');
|
|
64
|
+
const TUNNEL_ERR_LOG = join(DATA, 'sync-tunnel.err.log');
|
|
65
|
+
|
|
66
|
+
// Watchdog (sync-join, v0.13) — hourly `sync-watchdog` pass that checks every
|
|
67
|
+
// remote's last_sync_at + the tunnel unit and surfaces silent failures
|
|
68
|
+
// (notification + alert file). The 2026-06 incident — a dead tunnel silently
|
|
69
|
+
// stranding 6 days of data — is exactly what this catches on day one.
|
|
70
|
+
const WD_MAC_LABEL = 'com.parallelclaw.memex.syncwatchdog';
|
|
71
|
+
const WD_MAC_PLIST = join(HOME, 'Library', 'LaunchAgents', `${WD_MAC_LABEL}.plist`);
|
|
72
|
+
const WD_LINUX_SERVICE = 'memex-sync-watchdog.service';
|
|
73
|
+
const WD_LINUX_TIMER = 'memex-sync-watchdog.timer';
|
|
74
|
+
const WD_SERVICE_PATH = join(LINUX_DIR, WD_LINUX_SERVICE);
|
|
75
|
+
const WD_TIMER_PATH = join(LINUX_DIR, WD_LINUX_TIMER);
|
|
76
|
+
const WD_OUT_LOG = join(DATA, 'sync-watchdog.out.log');
|
|
77
|
+
const WD_ERR_LOG = join(DATA, 'sync-watchdog.err.log');
|
|
78
|
+
|
|
79
|
+
export const SERVICE_PATHS = {
|
|
80
|
+
MAC_LABEL, MAC_PLIST, LINUX_UNIT, LINUX_DIR, LINUX_PATH, OUT_LOG, ERR_LOG,
|
|
81
|
+
SCHED_MAC_LABEL, SCHED_MAC_PLIST, SCHED_LINUX_SERVICE, SCHED_LINUX_TIMER,
|
|
82
|
+
SCHED_SERVICE_PATH, SCHED_TIMER_PATH,
|
|
83
|
+
TUNNEL_MAC_LABEL, TUNNEL_MAC_PLIST, TUNNEL_LINUX_UNIT, TUNNEL_LINUX_PATH, TUNNEL_SCRIPT,
|
|
84
|
+
WD_MAC_LABEL, WD_MAC_PLIST, WD_LINUX_SERVICE, WD_LINUX_TIMER, WD_SERVICE_PATH, WD_TIMER_PATH,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Install + start the sync-server as a managed service.
|
|
89
|
+
*
|
|
90
|
+
* opts:
|
|
91
|
+
* scriptPath — absolute path to ingest.js (defaults to process.argv[1])
|
|
92
|
+
* port, bind — listen config baked into the unit's ExecStart
|
|
93
|
+
* nodePath — node binary (defaults to process.execPath)
|
|
94
|
+
*
|
|
95
|
+
* Returns { platform, unitPath } on success; throws on failure.
|
|
96
|
+
*/
|
|
97
|
+
export function installSyncServerService({ scriptPath, port, bind, nodePath = process.execPath } = {}) {
|
|
98
|
+
const script = resolve(scriptPath || process.argv[1]);
|
|
99
|
+
if (!existsSync(script)) {
|
|
100
|
+
throw new Error(`installSyncServerService: script not found at ${script}`);
|
|
101
|
+
}
|
|
102
|
+
mkdirSync(DATA, { recursive: true });
|
|
103
|
+
|
|
104
|
+
if (platform() === 'darwin') return installLaunchAgent({ script, port, bind, nodePath });
|
|
105
|
+
if (platform() === 'linux') return installSystemd({ script, port, bind, nodePath });
|
|
106
|
+
throw new Error(`installSyncServerService: unsupported platform ${platform()}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function uninstallSyncServerService() {
|
|
110
|
+
if (platform() === 'darwin') return uninstallLaunchAgent();
|
|
111
|
+
if (platform() === 'linux') return uninstallSystemd();
|
|
112
|
+
throw new Error(`uninstallSyncServerService: unsupported platform ${platform()}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Report service state: { installed, running, manager, unitPath, detail }.
|
|
117
|
+
* Best-effort — never throws.
|
|
118
|
+
*/
|
|
119
|
+
export function syncServerServiceStatus() {
|
|
120
|
+
if (platform() === 'darwin') {
|
|
121
|
+
const installed = existsSync(MAC_PLIST);
|
|
122
|
+
let running = false, detail = '';
|
|
123
|
+
if (installed) {
|
|
124
|
+
try {
|
|
125
|
+
const out = execSync(`launchctl list 2>/dev/null | grep ${MAC_LABEL} || true`, { encoding: 'utf-8' });
|
|
126
|
+
running = out.trim().length > 0 && !out.trim().startsWith('-');
|
|
127
|
+
detail = out.trim();
|
|
128
|
+
} catch (_) {}
|
|
129
|
+
}
|
|
130
|
+
return { installed, running, manager: 'launchd', unitPath: MAC_PLIST, detail };
|
|
131
|
+
}
|
|
132
|
+
if (platform() === 'linux') {
|
|
133
|
+
const installed = existsSync(LINUX_PATH);
|
|
134
|
+
let running = false, detail = '';
|
|
135
|
+
if (installed) {
|
|
136
|
+
try {
|
|
137
|
+
detail = execSync(`systemctl --user is-active ${LINUX_UNIT} 2>/dev/null || true`, { encoding: 'utf-8' }).trim();
|
|
138
|
+
running = detail === 'active';
|
|
139
|
+
} catch (_) {}
|
|
140
|
+
}
|
|
141
|
+
return { installed, running, manager: 'systemd-user', unitPath: LINUX_PATH, detail };
|
|
142
|
+
}
|
|
143
|
+
return { installed: false, running: false, manager: 'none', unitPath: null, detail: 'unsupported platform' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── macOS LaunchAgent ────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Pure builder — returns the LaunchAgent plist XML. Exported for testing so
|
|
150
|
+
* we can assert the env var / args / paths without touching launchctl.
|
|
151
|
+
*/
|
|
152
|
+
export function buildLaunchAgentPlist({ script, port, bind, nodePath }) {
|
|
153
|
+
const args = ['sync-server', 'start'];
|
|
154
|
+
if (port) args.push('--port', String(port));
|
|
155
|
+
if (bind) args.push('--bind', String(bind));
|
|
156
|
+
const argXml = [nodePath, script, ...args].map((a) => ` <string>${a}</string>`).join('\n');
|
|
157
|
+
|
|
158
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
159
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
160
|
+
<plist version="1.0">
|
|
161
|
+
<dict>
|
|
162
|
+
<key>Label</key>
|
|
163
|
+
<string>${MAC_LABEL}</string>
|
|
164
|
+
<key>ProgramArguments</key>
|
|
165
|
+
<array>
|
|
166
|
+
${argXml}
|
|
167
|
+
</array>
|
|
168
|
+
<key>EnvironmentVariables</key>
|
|
169
|
+
<dict>
|
|
170
|
+
<key>MEMEX_SYNC_EXPERIMENTAL</key><string>1</string>
|
|
171
|
+
<key>HOME</key><string>${HOME}</string>
|
|
172
|
+
<key>MEMEX_DIR</key><string>${MEMEX_DIR}</string>
|
|
173
|
+
</dict>
|
|
174
|
+
<key>RunAtLoad</key><true/>
|
|
175
|
+
<key>KeepAlive</key><true/>
|
|
176
|
+
<key>ProcessType</key><string>Background</string>
|
|
177
|
+
<key>StandardOutPath</key><string>${OUT_LOG}</string>
|
|
178
|
+
<key>StandardErrorPath</key><string>${ERR_LOG}</string>
|
|
179
|
+
<key>WorkingDirectory</key><string>${resolve(script, '..')}</string>
|
|
180
|
+
</dict>
|
|
181
|
+
</plist>
|
|
182
|
+
`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function installLaunchAgent({ script, port, bind, nodePath }) {
|
|
186
|
+
const plist = buildLaunchAgentPlist({ script, port, bind, nodePath });
|
|
187
|
+
mkdirSync(join(HOME, 'Library', 'LaunchAgents'), { recursive: true });
|
|
188
|
+
try { execSync(`launchctl unload ${JSON.stringify(MAC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
|
|
189
|
+
writeFileSync(MAC_PLIST, plist);
|
|
190
|
+
execSync(`launchctl load ${JSON.stringify(MAC_PLIST)}`, { stdio: 'inherit' });
|
|
191
|
+
return { platform: 'darwin', unitPath: MAC_PLIST };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function uninstallLaunchAgent() {
|
|
195
|
+
try { execSync(`launchctl unload ${JSON.stringify(MAC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
|
|
196
|
+
if (existsSync(MAC_PLIST)) unlinkSync(MAC_PLIST);
|
|
197
|
+
return { platform: 'darwin', unitPath: MAC_PLIST };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Linux systemd-user ───────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Pure builder — returns the systemd-user unit file content. Exported for
|
|
204
|
+
* testing so we can assert env var / ExecStart / restart policy without
|
|
205
|
+
* touching systemctl.
|
|
206
|
+
*/
|
|
207
|
+
export function buildSystemdUnit({ script, port, bind, nodePath }) {
|
|
208
|
+
const args = ['sync-server', 'start'];
|
|
209
|
+
if (port) args.push('--port', String(port));
|
|
210
|
+
if (bind) args.push('--bind', String(bind));
|
|
211
|
+
const execStart = [nodePath, script, ...args].join(' ');
|
|
212
|
+
|
|
213
|
+
return `[Unit]
|
|
214
|
+
Description=memex sync server (experimental multi-device replication)
|
|
215
|
+
Documentation=https://github.com/parallelclaw/memex-mvp/blob/main/SYNC.md
|
|
216
|
+
After=network.target
|
|
217
|
+
|
|
218
|
+
[Service]
|
|
219
|
+
Type=simple
|
|
220
|
+
ExecStart=${execStart}
|
|
221
|
+
WorkingDirectory=${resolve(script, '..')}
|
|
222
|
+
Restart=on-failure
|
|
223
|
+
RestartSec=10s
|
|
224
|
+
StartLimitIntervalSec=60
|
|
225
|
+
StartLimitBurst=5
|
|
226
|
+
Environment=MEMEX_SYNC_EXPERIMENTAL=1
|
|
227
|
+
Environment=HOME=${HOME}
|
|
228
|
+
Environment=MEMEX_DIR=${MEMEX_DIR}
|
|
229
|
+
StandardOutput=append:${OUT_LOG}
|
|
230
|
+
StandardError=append:${ERR_LOG}
|
|
231
|
+
|
|
232
|
+
[Install]
|
|
233
|
+
WantedBy=default.target
|
|
234
|
+
`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function installSystemd({ script, port, bind, nodePath }) {
|
|
238
|
+
try { execSync('systemctl --user --version', { stdio: 'ignore' }); }
|
|
239
|
+
catch (_) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
'systemctl --user not available. Run the server under nohup instead, ' +
|
|
242
|
+
'or enable lingering: `loginctl enable-linger $USER`.'
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const unit = buildSystemdUnit({ script, port, bind, nodePath });
|
|
247
|
+
mkdirSync(LINUX_DIR, { recursive: true });
|
|
248
|
+
try { execSync(`systemctl --user stop ${LINUX_UNIT}`, { stdio: 'ignore' }); } catch (_) {}
|
|
249
|
+
writeFileSync(LINUX_PATH, unit);
|
|
250
|
+
execSync('systemctl --user daemon-reload', { stdio: 'inherit' });
|
|
251
|
+
execSync(`systemctl --user enable ${LINUX_UNIT}`, { stdio: 'inherit' });
|
|
252
|
+
execSync(`systemctl --user start ${LINUX_UNIT}`, { stdio: 'inherit' });
|
|
253
|
+
return { platform: 'linux', unitPath: LINUX_PATH };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function uninstallSystemd() {
|
|
257
|
+
try { execSync(`systemctl --user stop ${LINUX_UNIT}`, { stdio: 'ignore' }); } catch (_) {}
|
|
258
|
+
try { execSync(`systemctl --user disable ${LINUX_UNIT}`, { stdio: 'ignore' }); } catch (_) {}
|
|
259
|
+
if (existsSync(LINUX_PATH)) unlinkSync(LINUX_PATH);
|
|
260
|
+
try { execSync('systemctl --user daemon-reload', { stdio: 'ignore' }); } catch (_) {}
|
|
261
|
+
return { platform: 'linux', unitPath: LINUX_PATH };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
265
|
+
// Phase 3 · scheduled auto-sync (client side)
|
|
266
|
+
// Runs `sync-run --all` every N minutes via the platform scheduler.
|
|
267
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Install the recurring auto-sync schedule.
|
|
271
|
+
* opts.everyMinutes — interval (default 15)
|
|
272
|
+
* opts.scriptPath — ingest.js (defaults to process.argv[1])
|
|
273
|
+
* opts.nodePath — node binary (defaults to process.execPath)
|
|
274
|
+
*/
|
|
275
|
+
export function installSyncSchedule({ scriptPath, everyMinutes = 15, nodePath = process.execPath } = {}) {
|
|
276
|
+
const script = resolve(scriptPath || process.argv[1]);
|
|
277
|
+
if (!existsSync(script)) throw new Error(`installSyncSchedule: script not found at ${script}`);
|
|
278
|
+
const mins = Math.max(1, Math.floor(Number(everyMinutes) || 15));
|
|
279
|
+
mkdirSync(DATA, { recursive: true });
|
|
280
|
+
|
|
281
|
+
if (platform() === 'darwin') return installScheduleLaunchAgent({ script, mins, nodePath });
|
|
282
|
+
if (platform() === 'linux') return installScheduleSystemd({ script, mins, nodePath });
|
|
283
|
+
throw new Error(`installSyncSchedule: unsupported platform ${platform()}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function uninstallSyncSchedule() {
|
|
287
|
+
if (platform() === 'darwin') {
|
|
288
|
+
try { execSync(`launchctl unload ${JSON.stringify(SCHED_MAC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
|
|
289
|
+
if (existsSync(SCHED_MAC_PLIST)) unlinkSync(SCHED_MAC_PLIST);
|
|
290
|
+
return { platform: 'darwin', unitPath: SCHED_MAC_PLIST };
|
|
291
|
+
}
|
|
292
|
+
if (platform() === 'linux') {
|
|
293
|
+
try { execSync(`systemctl --user stop ${SCHED_LINUX_TIMER}`, { stdio: 'ignore' }); } catch (_) {}
|
|
294
|
+
try { execSync(`systemctl --user disable ${SCHED_LINUX_TIMER}`, { stdio: 'ignore' }); } catch (_) {}
|
|
295
|
+
if (existsSync(SCHED_TIMER_PATH)) unlinkSync(SCHED_TIMER_PATH);
|
|
296
|
+
if (existsSync(SCHED_SERVICE_PATH)) unlinkSync(SCHED_SERVICE_PATH);
|
|
297
|
+
try { execSync('systemctl --user daemon-reload', { stdio: 'ignore' }); } catch (_) {}
|
|
298
|
+
return { platform: 'linux', unitPath: SCHED_TIMER_PATH };
|
|
299
|
+
}
|
|
300
|
+
throw new Error(`uninstallSyncSchedule: unsupported platform ${platform()}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** { installed, running, manager, everyMinutes?, unitPath, detail } — best-effort. */
|
|
304
|
+
export function syncScheduleStatus() {
|
|
305
|
+
if (platform() === 'darwin') {
|
|
306
|
+
const installed = existsSync(SCHED_MAC_PLIST);
|
|
307
|
+
let running = false, detail = '';
|
|
308
|
+
if (installed) {
|
|
309
|
+
try {
|
|
310
|
+
const out = execSync(`launchctl list 2>/dev/null | grep ${SCHED_MAC_LABEL} || true`, { encoding: 'utf-8' });
|
|
311
|
+
running = out.trim().length > 0;
|
|
312
|
+
detail = out.trim();
|
|
313
|
+
} catch (_) {}
|
|
314
|
+
}
|
|
315
|
+
return { installed, running, manager: 'launchd', unitPath: SCHED_MAC_PLIST, detail };
|
|
316
|
+
}
|
|
317
|
+
if (platform() === 'linux') {
|
|
318
|
+
const installed = existsSync(SCHED_TIMER_PATH);
|
|
319
|
+
let running = false, detail = '';
|
|
320
|
+
if (installed) {
|
|
321
|
+
try {
|
|
322
|
+
detail = execSync(`systemctl --user is-active ${SCHED_LINUX_TIMER} 2>/dev/null || true`, { encoding: 'utf-8' }).trim();
|
|
323
|
+
running = detail === 'active';
|
|
324
|
+
} catch (_) {}
|
|
325
|
+
}
|
|
326
|
+
return { installed, running, manager: 'systemd-user', unitPath: SCHED_TIMER_PATH, detail };
|
|
327
|
+
}
|
|
328
|
+
return { installed: false, running: false, manager: 'none', unitPath: null, detail: 'unsupported' };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── macOS: LaunchAgent with StartInterval (re-runs the one-shot every N sec) ──
|
|
332
|
+
|
|
333
|
+
export function buildScheduleLaunchAgentPlist({ script, mins, nodePath }) {
|
|
334
|
+
const interval = mins * 60;
|
|
335
|
+
const argXml = [nodePath, script, 'sync-run', '--all']
|
|
336
|
+
.map((a) => ` <string>${a}</string>`).join('\n');
|
|
337
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
338
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
339
|
+
<plist version="1.0">
|
|
340
|
+
<dict>
|
|
341
|
+
<key>Label</key>
|
|
342
|
+
<string>${SCHED_MAC_LABEL}</string>
|
|
343
|
+
<key>ProgramArguments</key>
|
|
344
|
+
<array>
|
|
345
|
+
${argXml}
|
|
346
|
+
</array>
|
|
347
|
+
<key>EnvironmentVariables</key>
|
|
348
|
+
<dict>
|
|
349
|
+
<key>MEMEX_SYNC_EXPERIMENTAL</key><string>1</string>
|
|
350
|
+
<key>HOME</key><string>${HOME}</string>
|
|
351
|
+
<key>MEMEX_DIR</key><string>${MEMEX_DIR}</string>
|
|
352
|
+
</dict>
|
|
353
|
+
<key>RunAtLoad</key><true/>
|
|
354
|
+
<key>StartInterval</key><integer>${interval}</integer>
|
|
355
|
+
<key>ProcessType</key><string>Background</string>
|
|
356
|
+
<key>StandardOutPath</key><string>${SCHED_OUT_LOG}</string>
|
|
357
|
+
<key>StandardErrorPath</key><string>${SCHED_ERR_LOG}</string>
|
|
358
|
+
<key>WorkingDirectory</key><string>${resolve(script, '..')}</string>
|
|
359
|
+
</dict>
|
|
360
|
+
</plist>
|
|
361
|
+
`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function installScheduleLaunchAgent({ script, mins, nodePath }) {
|
|
365
|
+
const plist = buildScheduleLaunchAgentPlist({ script, mins, nodePath });
|
|
366
|
+
mkdirSync(join(HOME, 'Library', 'LaunchAgents'), { recursive: true });
|
|
367
|
+
try { execSync(`launchctl unload ${JSON.stringify(SCHED_MAC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
|
|
368
|
+
writeFileSync(SCHED_MAC_PLIST, plist);
|
|
369
|
+
execSync(`launchctl load ${JSON.stringify(SCHED_MAC_PLIST)}`, { stdio: 'inherit' });
|
|
370
|
+
return { platform: 'darwin', unitPath: SCHED_MAC_PLIST, everyMinutes: mins };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Linux: systemd .timer + oneshot .service ─────────────────────────────────
|
|
374
|
+
|
|
375
|
+
export function buildScheduleSystemdService({ script, nodePath }) {
|
|
376
|
+
return `[Unit]
|
|
377
|
+
Description=memex sync — one auto-sync pass (all remotes)
|
|
378
|
+
Documentation=https://github.com/parallelclaw/memex-mvp/blob/main/SYNC.md
|
|
379
|
+
|
|
380
|
+
[Service]
|
|
381
|
+
Type=oneshot
|
|
382
|
+
ExecStart=${nodePath} ${script} sync-run --all
|
|
383
|
+
WorkingDirectory=${resolve(script, '..')}
|
|
384
|
+
Environment=MEMEX_SYNC_EXPERIMENTAL=1
|
|
385
|
+
Environment=HOME=${HOME}
|
|
386
|
+
Environment=MEMEX_DIR=${MEMEX_DIR}
|
|
387
|
+
StandardOutput=append:${SCHED_OUT_LOG}
|
|
388
|
+
StandardError=append:${SCHED_ERR_LOG}
|
|
389
|
+
`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function buildScheduleSystemdTimer({ mins }) {
|
|
393
|
+
return `[Unit]
|
|
394
|
+
Description=memex sync — run auto-sync every ${mins}m
|
|
395
|
+
Documentation=https://github.com/parallelclaw/memex-mvp/blob/main/SYNC.md
|
|
396
|
+
|
|
397
|
+
[Timer]
|
|
398
|
+
OnBootSec=2min
|
|
399
|
+
OnUnitActiveSec=${mins}min
|
|
400
|
+
AccuracySec=30s
|
|
401
|
+
Persistent=true
|
|
402
|
+
|
|
403
|
+
[Install]
|
|
404
|
+
WantedBy=timers.target
|
|
405
|
+
`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function installScheduleSystemd({ script, mins, nodePath }) {
|
|
409
|
+
try { execSync('systemctl --user --version', { stdio: 'ignore' }); }
|
|
410
|
+
catch (_) {
|
|
411
|
+
throw new Error('systemctl --user not available. Enable lingering (loginctl enable-linger $USER) or run sync manually.');
|
|
412
|
+
}
|
|
413
|
+
mkdirSync(LINUX_DIR, { recursive: true });
|
|
414
|
+
writeFileSync(SCHED_SERVICE_PATH, buildScheduleSystemdService({ script, nodePath }));
|
|
415
|
+
writeFileSync(SCHED_TIMER_PATH, buildScheduleSystemdTimer({ mins }));
|
|
416
|
+
execSync('systemctl --user daemon-reload', { stdio: 'inherit' });
|
|
417
|
+
execSync(`systemctl --user enable ${SCHED_LINUX_TIMER}`, { stdio: 'inherit' });
|
|
418
|
+
execSync(`systemctl --user start ${SCHED_LINUX_TIMER}`, { stdio: 'inherit' });
|
|
419
|
+
return { platform: 'linux', unitPath: SCHED_TIMER_PATH, everyMinutes: mins };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
423
|
+
// sync-join (v0.13) · durable forward tunnel — spoke → hub loopback
|
|
424
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Pure builder — the tunnel keeper script. Runs ssh in the FOREGROUND
|
|
428
|
+
* (`-N`, no `-f`): the OS supervisor owns the lifecycle and respawns on any
|
|
429
|
+
* exit. ServerAlive* makes ssh notice a dead peer within ~90s and exit so
|
|
430
|
+
* the supervisor can heal it. ExitOnForwardFailure turns a failed -L bind
|
|
431
|
+
* into an exit (→ respawn with backoff) instead of a silent no-op tunnel.
|
|
432
|
+
* Explicit IPv4 loopback on both sides — a bare port once bound ::1-only
|
|
433
|
+
* and produced a tunnel nobody dialed.
|
|
434
|
+
*/
|
|
435
|
+
export function buildTunnelScript({ sshTarget, localPort, remotePort, identity = null }) {
|
|
436
|
+
if (!sshTarget) throw new Error('buildTunnelScript: sshTarget required');
|
|
437
|
+
const lp = Number(localPort) || 8766;
|
|
438
|
+
const rp = Number(remotePort) || 8766;
|
|
439
|
+
// Either '' or a full continuation line — the template line before it always
|
|
440
|
+
// ends in `\\`, so this must NEVER inject a bare ` \\` (a backslash-escaped
|
|
441
|
+
// space becomes a literal space argument to ssh and breaks the tunnel).
|
|
442
|
+
const idFlags = identity ? `\n -i "${identity}" -o IdentitiesOnly=yes \\` : '';
|
|
443
|
+
return `#!/bin/bash
|
|
444
|
+
# memex sync tunnel keeper — generated by \`memex-sync sync-join\`. Do not edit;
|
|
445
|
+
# re-run sync-join to regenerate. Managed by the OS service alongside it.
|
|
446
|
+
exec ssh -N \\
|
|
447
|
+
-o BatchMode=yes \\
|
|
448
|
+
-o StrictHostKeyChecking=accept-new \\
|
|
449
|
+
-o ExitOnForwardFailure=yes \\
|
|
450
|
+
-o ServerAliveInterval=30 \\
|
|
451
|
+
-o ServerAliveCountMax=3 \\${idFlags}
|
|
452
|
+
-L 127.0.0.1:${lp}:127.0.0.1:${rp} \\
|
|
453
|
+
"${sshTarget}"
|
|
454
|
+
`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Pure builder — launchd plist for the tunnel keeper (KeepAlive = self-heal). */
|
|
458
|
+
export function buildTunnelLaunchAgentPlist() {
|
|
459
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
460
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
461
|
+
<plist version="1.0">
|
|
462
|
+
<dict>
|
|
463
|
+
<key>Label</key>
|
|
464
|
+
<string>${TUNNEL_MAC_LABEL}</string>
|
|
465
|
+
<key>ProgramArguments</key>
|
|
466
|
+
<array>
|
|
467
|
+
<string>${TUNNEL_SCRIPT}</string>
|
|
468
|
+
</array>
|
|
469
|
+
<key>RunAtLoad</key><true/>
|
|
470
|
+
<key>KeepAlive</key><true/>
|
|
471
|
+
<key>ThrottleInterval</key><integer>15</integer>
|
|
472
|
+
<key>ProcessType</key><string>Background</string>
|
|
473
|
+
<key>StandardOutPath</key><string>${TUNNEL_OUT_LOG}</string>
|
|
474
|
+
<key>StandardErrorPath</key><string>${TUNNEL_ERR_LOG}</string>
|
|
475
|
+
</dict>
|
|
476
|
+
</plist>
|
|
477
|
+
`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Pure builder — systemd-user unit for the tunnel keeper. */
|
|
481
|
+
export function buildTunnelSystemdUnit() {
|
|
482
|
+
return `[Unit]
|
|
483
|
+
Description=memex sync tunnel keeper (self-healing forward SSH tunnel to hub)
|
|
484
|
+
Documentation=https://github.com/parallelclaw/memex-mvp/blob/main/SYNC.md
|
|
485
|
+
After=network-online.target
|
|
486
|
+
Wants=network-online.target
|
|
487
|
+
|
|
488
|
+
[Service]
|
|
489
|
+
Type=simple
|
|
490
|
+
ExecStart=${TUNNEL_SCRIPT}
|
|
491
|
+
Restart=always
|
|
492
|
+
RestartSec=15
|
|
493
|
+
StandardOutput=append:${TUNNEL_OUT_LOG}
|
|
494
|
+
StandardError=append:${TUNNEL_ERR_LOG}
|
|
495
|
+
|
|
496
|
+
[Install]
|
|
497
|
+
WantedBy=default.target
|
|
498
|
+
`;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Install (or replace) the durable tunnel. Writes the keeper script, then
|
|
503
|
+
* registers the supervisor unit. Idempotent: an existing tunnel is stopped
|
|
504
|
+
* and replaced — which also frees its local port before the new bind.
|
|
505
|
+
*/
|
|
506
|
+
export function installSyncTunnel({ sshTarget, localPort, remotePort, identity } = {}) {
|
|
507
|
+
if (!sshTarget) throw new Error('installSyncTunnel: sshTarget required');
|
|
508
|
+
mkdirSync(DATA, { recursive: true });
|
|
509
|
+
writeFileSync(TUNNEL_SCRIPT, buildTunnelScript({ sshTarget, localPort, remotePort, identity }), { mode: 0o755 });
|
|
510
|
+
|
|
511
|
+
if (platform() === 'darwin') {
|
|
512
|
+
try { execSync(`launchctl unload ${JSON.stringify(TUNNEL_MAC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
|
|
513
|
+
mkdirSync(join(HOME, 'Library', 'LaunchAgents'), { recursive: true });
|
|
514
|
+
writeFileSync(TUNNEL_MAC_PLIST, buildTunnelLaunchAgentPlist());
|
|
515
|
+
execSync(`launchctl load ${JSON.stringify(TUNNEL_MAC_PLIST)}`, { stdio: 'inherit' });
|
|
516
|
+
return { platform: 'darwin', unitPath: TUNNEL_MAC_PLIST, scriptPath: TUNNEL_SCRIPT };
|
|
517
|
+
}
|
|
518
|
+
if (platform() === 'linux') {
|
|
519
|
+
try { execSync('systemctl --user --version', { stdio: 'ignore' }); }
|
|
520
|
+
catch (_) { throw new Error('systemctl --user not available — enable lingering: loginctl enable-linger $USER'); }
|
|
521
|
+
mkdirSync(LINUX_DIR, { recursive: true });
|
|
522
|
+
try { execSync(`systemctl --user stop ${TUNNEL_LINUX_UNIT}`, { stdio: 'ignore' }); } catch (_) {}
|
|
523
|
+
writeFileSync(TUNNEL_LINUX_PATH, buildTunnelSystemdUnit());
|
|
524
|
+
execSync('systemctl --user daemon-reload', { stdio: 'inherit' });
|
|
525
|
+
execSync(`systemctl --user enable ${TUNNEL_LINUX_UNIT}`, { stdio: 'inherit' });
|
|
526
|
+
execSync(`systemctl --user start ${TUNNEL_LINUX_UNIT}`, { stdio: 'inherit' });
|
|
527
|
+
return { platform: 'linux', unitPath: TUNNEL_LINUX_PATH, scriptPath: TUNNEL_SCRIPT };
|
|
528
|
+
}
|
|
529
|
+
throw new Error(`installSyncTunnel: unsupported platform ${platform()}`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export function uninstallSyncTunnel() {
|
|
533
|
+
if (platform() === 'darwin') {
|
|
534
|
+
try { execSync(`launchctl unload ${JSON.stringify(TUNNEL_MAC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
|
|
535
|
+
if (existsSync(TUNNEL_MAC_PLIST)) unlinkSync(TUNNEL_MAC_PLIST);
|
|
536
|
+
if (existsSync(TUNNEL_SCRIPT)) unlinkSync(TUNNEL_SCRIPT);
|
|
537
|
+
return { platform: 'darwin', unitPath: TUNNEL_MAC_PLIST };
|
|
538
|
+
}
|
|
539
|
+
if (platform() === 'linux') {
|
|
540
|
+
try { execSync(`systemctl --user stop ${TUNNEL_LINUX_UNIT}`, { stdio: 'ignore' }); } catch (_) {}
|
|
541
|
+
try { execSync(`systemctl --user disable ${TUNNEL_LINUX_UNIT}`, { stdio: 'ignore' }); } catch (_) {}
|
|
542
|
+
if (existsSync(TUNNEL_LINUX_PATH)) unlinkSync(TUNNEL_LINUX_PATH);
|
|
543
|
+
if (existsSync(TUNNEL_SCRIPT)) unlinkSync(TUNNEL_SCRIPT);
|
|
544
|
+
try { execSync('systemctl --user daemon-reload', { stdio: 'ignore' }); } catch (_) {}
|
|
545
|
+
return { platform: 'linux', unitPath: TUNNEL_LINUX_PATH };
|
|
546
|
+
}
|
|
547
|
+
throw new Error(`uninstallSyncTunnel: unsupported platform ${platform()}`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* { installed, running, manager, unitPath, scriptPath, spec } — best-effort.
|
|
552
|
+
* `spec` is parsed back out of the generated script ({sshTarget, localPort,
|
|
553
|
+
* remotePort}) so status can describe the tunnel without extra state files.
|
|
554
|
+
*/
|
|
555
|
+
export function syncTunnelStatus() {
|
|
556
|
+
let spec = null;
|
|
557
|
+
if (existsSync(TUNNEL_SCRIPT)) {
|
|
558
|
+
try {
|
|
559
|
+
const body = readFileSync(TUNNEL_SCRIPT, 'utf-8');
|
|
560
|
+
const m = body.match(/-L 127\.0\.0\.1:(\d+):127\.0\.0\.1:(\d+)[\s\S]*?"([^"]+)"/);
|
|
561
|
+
if (m) spec = { localPort: Number(m[1]), remotePort: Number(m[2]), sshTarget: m[3] };
|
|
562
|
+
} catch (_) {}
|
|
563
|
+
}
|
|
564
|
+
if (platform() === 'darwin') {
|
|
565
|
+
const installed = existsSync(TUNNEL_MAC_PLIST);
|
|
566
|
+
let running = false, detail = '';
|
|
567
|
+
if (installed) {
|
|
568
|
+
try {
|
|
569
|
+
const out = execSync(`launchctl list 2>/dev/null | grep ${TUNNEL_MAC_LABEL} || true`, { encoding: 'utf-8' });
|
|
570
|
+
running = out.trim().length > 0 && !out.trim().startsWith('-');
|
|
571
|
+
detail = out.trim();
|
|
572
|
+
} catch (_) {}
|
|
573
|
+
}
|
|
574
|
+
return { installed, running, manager: 'launchd', unitPath: TUNNEL_MAC_PLIST, scriptPath: TUNNEL_SCRIPT, spec, detail };
|
|
575
|
+
}
|
|
576
|
+
if (platform() === 'linux') {
|
|
577
|
+
const installed = existsSync(TUNNEL_LINUX_PATH);
|
|
578
|
+
let running = false, detail = '';
|
|
579
|
+
if (installed) {
|
|
580
|
+
try {
|
|
581
|
+
detail = execSync(`systemctl --user is-active ${TUNNEL_LINUX_UNIT} 2>/dev/null || true`, { encoding: 'utf-8' }).trim();
|
|
582
|
+
running = detail === 'active';
|
|
583
|
+
} catch (_) {}
|
|
584
|
+
}
|
|
585
|
+
return { installed, running, manager: 'systemd-user', unitPath: TUNNEL_LINUX_PATH, scriptPath: TUNNEL_SCRIPT, spec, detail };
|
|
586
|
+
}
|
|
587
|
+
return { installed: false, running: false, manager: 'none', unitPath: null, scriptPath: TUNNEL_SCRIPT, spec, detail: 'unsupported' };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
591
|
+
// sync-join (v0.13) · watchdog timer — hourly silent-failure detector
|
|
592
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
593
|
+
|
|
594
|
+
/** Pure builder — launchd plist running `sync-watchdog` every N minutes. */
|
|
595
|
+
export function buildWatchdogLaunchAgentPlist({ script, mins = 60, nodePath }) {
|
|
596
|
+
const argXml = [nodePath, script, 'sync-watchdog']
|
|
597
|
+
.map((a) => ` <string>${a}</string>`).join('\n');
|
|
598
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
599
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
600
|
+
<plist version="1.0">
|
|
601
|
+
<dict>
|
|
602
|
+
<key>Label</key>
|
|
603
|
+
<string>${WD_MAC_LABEL}</string>
|
|
604
|
+
<key>ProgramArguments</key>
|
|
605
|
+
<array>
|
|
606
|
+
${argXml}
|
|
607
|
+
</array>
|
|
608
|
+
<key>EnvironmentVariables</key>
|
|
609
|
+
<dict>
|
|
610
|
+
<key>MEMEX_SYNC_EXPERIMENTAL</key><string>1</string>
|
|
611
|
+
<key>HOME</key><string>${HOME}</string>
|
|
612
|
+
<key>MEMEX_DIR</key><string>${MEMEX_DIR}</string>
|
|
613
|
+
</dict>
|
|
614
|
+
<key>RunAtLoad</key><true/>
|
|
615
|
+
<key>StartInterval</key><integer>${Math.max(60, Math.floor(mins * 60))}</integer>
|
|
616
|
+
<key>ProcessType</key><string>Background</string>
|
|
617
|
+
<key>StandardOutPath</key><string>${WD_OUT_LOG}</string>
|
|
618
|
+
<key>StandardErrorPath</key><string>${WD_ERR_LOG}</string>
|
|
619
|
+
<key>WorkingDirectory</key><string>${resolve(script, '..')}</string>
|
|
620
|
+
</dict>
|
|
621
|
+
</plist>
|
|
622
|
+
`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Pure builders — systemd oneshot + timer for the watchdog. */
|
|
626
|
+
export function buildWatchdogSystemdService({ script, nodePath }) {
|
|
627
|
+
return `[Unit]
|
|
628
|
+
Description=memex sync — one watchdog pass (detect silent sync failure)
|
|
629
|
+
|
|
630
|
+
[Service]
|
|
631
|
+
Type=oneshot
|
|
632
|
+
ExecStart=${nodePath} ${script} sync-watchdog
|
|
633
|
+
WorkingDirectory=${resolve(script, '..')}
|
|
634
|
+
Environment=MEMEX_SYNC_EXPERIMENTAL=1
|
|
635
|
+
Environment=HOME=${HOME}
|
|
636
|
+
Environment=MEMEX_DIR=${MEMEX_DIR}
|
|
637
|
+
StandardOutput=append:${WD_OUT_LOG}
|
|
638
|
+
StandardError=append:${WD_ERR_LOG}
|
|
639
|
+
`;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export function buildWatchdogSystemdTimer({ mins = 60 }) {
|
|
643
|
+
return `[Unit]
|
|
644
|
+
Description=memex sync — watchdog every ${mins}m
|
|
645
|
+
|
|
646
|
+
[Timer]
|
|
647
|
+
OnBootSec=5min
|
|
648
|
+
OnUnitActiveSec=${mins}min
|
|
649
|
+
AccuracySec=1min
|
|
650
|
+
Persistent=true
|
|
651
|
+
|
|
652
|
+
[Install]
|
|
653
|
+
WantedBy=timers.target
|
|
654
|
+
`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export function installSyncWatchdog({ scriptPath, everyMinutes = 60, nodePath = process.execPath } = {}) {
|
|
658
|
+
const script = resolve(scriptPath || process.argv[1]);
|
|
659
|
+
if (!existsSync(script)) throw new Error(`installSyncWatchdog: script not found at ${script}`);
|
|
660
|
+
const mins = Math.max(5, Math.floor(Number(everyMinutes) || 60));
|
|
661
|
+
mkdirSync(DATA, { recursive: true });
|
|
662
|
+
|
|
663
|
+
if (platform() === 'darwin') {
|
|
664
|
+
try { execSync(`launchctl unload ${JSON.stringify(WD_MAC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
|
|
665
|
+
mkdirSync(join(HOME, 'Library', 'LaunchAgents'), { recursive: true });
|
|
666
|
+
writeFileSync(WD_MAC_PLIST, buildWatchdogLaunchAgentPlist({ script, mins, nodePath }));
|
|
667
|
+
execSync(`launchctl load ${JSON.stringify(WD_MAC_PLIST)}`, { stdio: 'inherit' });
|
|
668
|
+
return { platform: 'darwin', unitPath: WD_MAC_PLIST, everyMinutes: mins };
|
|
669
|
+
}
|
|
670
|
+
if (platform() === 'linux') {
|
|
671
|
+
try { execSync('systemctl --user --version', { stdio: 'ignore' }); }
|
|
672
|
+
catch (_) { throw new Error('systemctl --user not available — enable lingering: loginctl enable-linger $USER'); }
|
|
673
|
+
mkdirSync(LINUX_DIR, { recursive: true });
|
|
674
|
+
writeFileSync(WD_SERVICE_PATH, buildWatchdogSystemdService({ script, nodePath }));
|
|
675
|
+
writeFileSync(WD_TIMER_PATH, buildWatchdogSystemdTimer({ mins }));
|
|
676
|
+
execSync('systemctl --user daemon-reload', { stdio: 'inherit' });
|
|
677
|
+
execSync(`systemctl --user enable ${WD_LINUX_TIMER}`, { stdio: 'inherit' });
|
|
678
|
+
execSync(`systemctl --user start ${WD_LINUX_TIMER}`, { stdio: 'inherit' });
|
|
679
|
+
return { platform: 'linux', unitPath: WD_TIMER_PATH, everyMinutes: mins };
|
|
680
|
+
}
|
|
681
|
+
throw new Error(`installSyncWatchdog: unsupported platform ${platform()}`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export function uninstallSyncWatchdog() {
|
|
685
|
+
if (platform() === 'darwin') {
|
|
686
|
+
try { execSync(`launchctl unload ${JSON.stringify(WD_MAC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
|
|
687
|
+
if (existsSync(WD_MAC_PLIST)) unlinkSync(WD_MAC_PLIST);
|
|
688
|
+
return { platform: 'darwin', unitPath: WD_MAC_PLIST };
|
|
689
|
+
}
|
|
690
|
+
if (platform() === 'linux') {
|
|
691
|
+
try { execSync(`systemctl --user stop ${WD_LINUX_TIMER}`, { stdio: 'ignore' }); } catch (_) {}
|
|
692
|
+
try { execSync(`systemctl --user disable ${WD_LINUX_TIMER}`, { stdio: 'ignore' }); } catch (_) {}
|
|
693
|
+
if (existsSync(WD_TIMER_PATH)) unlinkSync(WD_TIMER_PATH);
|
|
694
|
+
if (existsSync(WD_SERVICE_PATH)) unlinkSync(WD_SERVICE_PATH);
|
|
695
|
+
try { execSync('systemctl --user daemon-reload', { stdio: 'ignore' }); } catch (_) {}
|
|
696
|
+
return { platform: 'linux', unitPath: WD_TIMER_PATH };
|
|
697
|
+
}
|
|
698
|
+
throw new Error(`uninstallSyncWatchdog: unsupported platform ${platform()}`);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export function syncWatchdogStatus() {
|
|
702
|
+
if (platform() === 'darwin') {
|
|
703
|
+
const installed = existsSync(WD_MAC_PLIST);
|
|
704
|
+
let running = false, detail = '';
|
|
705
|
+
if (installed) {
|
|
706
|
+
try {
|
|
707
|
+
const out = execSync(`launchctl list 2>/dev/null | grep ${WD_MAC_LABEL} || true`, { encoding: 'utf-8' });
|
|
708
|
+
running = out.trim().length > 0;
|
|
709
|
+
detail = out.trim();
|
|
710
|
+
} catch (_) {}
|
|
711
|
+
}
|
|
712
|
+
return { installed, running, manager: 'launchd', unitPath: WD_MAC_PLIST, detail };
|
|
713
|
+
}
|
|
714
|
+
if (platform() === 'linux') {
|
|
715
|
+
const installed = existsSync(WD_TIMER_PATH);
|
|
716
|
+
let running = false, detail = '';
|
|
717
|
+
if (installed) {
|
|
718
|
+
try {
|
|
719
|
+
detail = execSync(`systemctl --user is-active ${WD_LINUX_TIMER} 2>/dev/null || true`, { encoding: 'utf-8' }).trim();
|
|
720
|
+
running = detail === 'active';
|
|
721
|
+
} catch (_) {}
|
|
722
|
+
}
|
|
723
|
+
return { installed, running, manager: 'systemd-user', unitPath: WD_TIMER_PATH, detail };
|
|
724
|
+
}
|
|
725
|
+
return { installed: false, running: false, manager: 'none', unitPath: null, detail: 'unsupported' };
|
|
726
|
+
}
|