@masslessai/push-todo 4.2.1 → 4.2.3
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/lib/launchagent.js +200 -0
- package/package.json +1 -1
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LaunchAgent management for Push daemon.
|
|
3
|
+
*
|
|
4
|
+
* Installs/uninstalls a user-level LaunchAgent plist that keeps the
|
|
5
|
+
* Push daemon running across reboots and logins. User-level LaunchAgents
|
|
6
|
+
* do NOT require sudo — they live in ~/Library/LaunchAgents/.
|
|
7
|
+
*
|
|
8
|
+
* This supplements (not replaces) the self-healing ensureDaemonRunning()
|
|
9
|
+
* pattern. LaunchAgent ensures startup; self-healing handles edge cases.
|
|
10
|
+
*
|
|
11
|
+
* Historical context:
|
|
12
|
+
* - Happy Engineering rejected LaunchAgent citing sudo friction (wrong —
|
|
13
|
+
* user-level LaunchAgents don't need sudo)
|
|
14
|
+
* - OpenClaw proved zero-friction adoption with LaunchAgent at scale
|
|
15
|
+
* - See: docs/20260127_parallel_task_execution_research.md §9.9
|
|
16
|
+
*
|
|
17
|
+
* Architecture: docs/20260224_openclaw_autonomous_layer_research_and_push_integration_plan.md §Phase 6
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
|
21
|
+
import { execFileSync } from 'child_process';
|
|
22
|
+
import { homedir } from 'os';
|
|
23
|
+
import { join, dirname } from 'path';
|
|
24
|
+
import { fileURLToPath } from 'url';
|
|
25
|
+
|
|
26
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
27
|
+
const __dirname = dirname(__filename);
|
|
28
|
+
|
|
29
|
+
const LABEL = 'ai.massless.push.daemon';
|
|
30
|
+
const LAUNCH_AGENTS_DIR = join(homedir(), 'Library', 'LaunchAgents');
|
|
31
|
+
const PLIST_PATH = join(LAUNCH_AGENTS_DIR, `${LABEL}.plist`);
|
|
32
|
+
const PUSH_DIR = join(homedir(), '.push');
|
|
33
|
+
const LOG_FILE = join(PUSH_DIR, 'daemon.log');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Find the node binary used by push-todo.
|
|
37
|
+
*/
|
|
38
|
+
function findNodeBinary() {
|
|
39
|
+
try {
|
|
40
|
+
const path = execFileSync('which', ['node'], { encoding: 'utf8', timeout: 5000 }).trim();
|
|
41
|
+
if (path && existsSync(path)) return path;
|
|
42
|
+
} catch {}
|
|
43
|
+
return process.execPath;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate the plist XML content.
|
|
48
|
+
*/
|
|
49
|
+
function generatePlist() {
|
|
50
|
+
const nodeBin = findNodeBinary();
|
|
51
|
+
const daemonScript = join(__dirname, 'daemon.js');
|
|
52
|
+
|
|
53
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
54
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
55
|
+
<plist version="1.0">
|
|
56
|
+
<dict>
|
|
57
|
+
<key>Label</key>
|
|
58
|
+
<string>${LABEL}</string>
|
|
59
|
+
|
|
60
|
+
<key>ProgramArguments</key>
|
|
61
|
+
<array>
|
|
62
|
+
<string>${nodeBin}</string>
|
|
63
|
+
<string>${daemonScript}</string>
|
|
64
|
+
</array>
|
|
65
|
+
|
|
66
|
+
<key>EnvironmentVariables</key>
|
|
67
|
+
<dict>
|
|
68
|
+
<key>PUSH_DAEMON</key>
|
|
69
|
+
<string>1</string>
|
|
70
|
+
<key>PATH</key>
|
|
71
|
+
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${dirname(nodeBin)}</string>
|
|
72
|
+
</dict>
|
|
73
|
+
|
|
74
|
+
<key>RunAtLoad</key>
|
|
75
|
+
<true/>
|
|
76
|
+
|
|
77
|
+
<key>KeepAlive</key>
|
|
78
|
+
<true/>
|
|
79
|
+
|
|
80
|
+
<key>ThrottleInterval</key>
|
|
81
|
+
<integer>30</integer>
|
|
82
|
+
|
|
83
|
+
<key>StandardOutPath</key>
|
|
84
|
+
<string>${LOG_FILE}</string>
|
|
85
|
+
|
|
86
|
+
<key>StandardErrorPath</key>
|
|
87
|
+
<string>${LOG_FILE}</string>
|
|
88
|
+
|
|
89
|
+
<key>WorkingDirectory</key>
|
|
90
|
+
<string>${homedir()}</string>
|
|
91
|
+
</dict>
|
|
92
|
+
</plist>
|
|
93
|
+
`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if the LaunchAgent is installed.
|
|
98
|
+
* @returns {{ installed: boolean, loaded: boolean, plistPath: string }}
|
|
99
|
+
*/
|
|
100
|
+
export function getStatus() {
|
|
101
|
+
const installed = existsSync(PLIST_PATH);
|
|
102
|
+
let loaded = false;
|
|
103
|
+
|
|
104
|
+
if (installed) {
|
|
105
|
+
try {
|
|
106
|
+
execFileSync('launchctl', ['list', LABEL], {
|
|
107
|
+
timeout: 5000,
|
|
108
|
+
stdio: ['ignore', 'pipe', 'ignore'], // capture stdout, suppress stderr
|
|
109
|
+
});
|
|
110
|
+
loaded = true; // If no error thrown, service exists
|
|
111
|
+
} catch {
|
|
112
|
+
loaded = false; // launchctl exits non-zero if service not found
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { installed, loaded, plistPath: PLIST_PATH };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Install the LaunchAgent plist and load it.
|
|
121
|
+
*
|
|
122
|
+
* Safe to call multiple times — updates existing plist if daemon script
|
|
123
|
+
* path has changed (e.g., after npm update).
|
|
124
|
+
*
|
|
125
|
+
* @returns {{ success: boolean, message: string, alreadyInstalled?: boolean }}
|
|
126
|
+
*/
|
|
127
|
+
export function install() {
|
|
128
|
+
try {
|
|
129
|
+
// Ensure directories exist
|
|
130
|
+
mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true });
|
|
131
|
+
mkdirSync(PUSH_DIR, { recursive: true });
|
|
132
|
+
|
|
133
|
+
// Check if already installed with same content
|
|
134
|
+
const newContent = generatePlist();
|
|
135
|
+
if (existsSync(PLIST_PATH)) {
|
|
136
|
+
const existing = readFileSync(PLIST_PATH, 'utf8');
|
|
137
|
+
if (existing === newContent) {
|
|
138
|
+
// Ensure loaded (suppress stderr — already-loaded plist emits noise)
|
|
139
|
+
try {
|
|
140
|
+
execFileSync('launchctl', ['load', '-w', PLIST_PATH], {
|
|
141
|
+
timeout: 5000,
|
|
142
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
143
|
+
});
|
|
144
|
+
} catch {}
|
|
145
|
+
return { success: true, message: 'LaunchAgent already installed', alreadyInstalled: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Content changed (e.g., node path updated) — unload old, write new
|
|
149
|
+
try {
|
|
150
|
+
execFileSync('launchctl', ['unload', PLIST_PATH], {
|
|
151
|
+
timeout: 5000,
|
|
152
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
153
|
+
});
|
|
154
|
+
} catch {}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Write plist
|
|
158
|
+
writeFileSync(PLIST_PATH, newContent);
|
|
159
|
+
|
|
160
|
+
// Load it
|
|
161
|
+
execFileSync('launchctl', ['load', '-w', PLIST_PATH], {
|
|
162
|
+
timeout: 5000,
|
|
163
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return { success: true, message: 'LaunchAgent installed and loaded' };
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return { success: false, message: `Failed to install LaunchAgent: ${error.message}` };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Uninstall the LaunchAgent — unload and remove plist.
|
|
174
|
+
*
|
|
175
|
+
* @returns {{ success: boolean, message: string }}
|
|
176
|
+
*/
|
|
177
|
+
export function uninstall() {
|
|
178
|
+
try {
|
|
179
|
+
if (!existsSync(PLIST_PATH)) {
|
|
180
|
+
return { success: true, message: 'LaunchAgent not installed (nothing to remove)' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Unload
|
|
184
|
+
try {
|
|
185
|
+
execFileSync('launchctl', ['unload', PLIST_PATH], {
|
|
186
|
+
timeout: 5000,
|
|
187
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
188
|
+
});
|
|
189
|
+
} catch {}
|
|
190
|
+
|
|
191
|
+
// Remove plist
|
|
192
|
+
unlinkSync(PLIST_PATH);
|
|
193
|
+
|
|
194
|
+
return { success: true, message: 'LaunchAgent uninstalled' };
|
|
195
|
+
} catch (error) {
|
|
196
|
+
return { success: false, message: `Failed to uninstall LaunchAgent: ${error.message}` };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export { PLIST_PATH, LABEL };
|