@virtengine/openfleet 0.25.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/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* openfleet — Startup Service Manager
|
|
5
|
+
*
|
|
6
|
+
* Cross-platform registration for auto-starting openfleet on login/boot.
|
|
7
|
+
* Supports:
|
|
8
|
+
* - Windows: Task Scheduler (schtasks)
|
|
9
|
+
* - macOS: launchd (~/Library/LaunchAgents)
|
|
10
|
+
* - Linux: systemd user units (~/.config/systemd/user)
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* import { installStartupService, removeStartupService, getStartupStatus } from './startup-service.mjs';
|
|
14
|
+
*
|
|
15
|
+
* await installStartupService({ daemon: true }); // Install with --daemon flag
|
|
16
|
+
* await removeStartupService(); // Uninstall
|
|
17
|
+
* getStartupStatus(); // Check current state
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
21
|
+
import {
|
|
22
|
+
existsSync,
|
|
23
|
+
readFileSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
unlinkSync,
|
|
27
|
+
} from "node:fs";
|
|
28
|
+
import { resolve, dirname, basename } from "node:path";
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
|
|
32
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
|
|
34
|
+
const SERVICE_LABEL = "com.openfleet.service";
|
|
35
|
+
const TASK_NAME = "CodexMonitor";
|
|
36
|
+
const SYSTEMD_UNIT = "openfleet.service";
|
|
37
|
+
|
|
38
|
+
// ── Platform Detection ───────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function getPlatform() {
|
|
41
|
+
switch (process.platform) {
|
|
42
|
+
case "win32":
|
|
43
|
+
return "windows";
|
|
44
|
+
case "darwin":
|
|
45
|
+
return "macos";
|
|
46
|
+
case "linux":
|
|
47
|
+
return "linux";
|
|
48
|
+
default:
|
|
49
|
+
return "unsupported";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Path Helpers ─────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function getNodePath() {
|
|
56
|
+
return process.execPath;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getCliPath() {
|
|
60
|
+
return resolve(__dirname, "cli.mjs");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getWorkingDirectory() {
|
|
64
|
+
return __dirname;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getLogDir() {
|
|
68
|
+
const dir = resolve(__dirname, "logs");
|
|
69
|
+
mkdirSync(dir, { recursive: true });
|
|
70
|
+
return dir;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Windows: Task Scheduler ──────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check whether the current process is running elevated (admin).
|
|
77
|
+
*/
|
|
78
|
+
function isElevated() {
|
|
79
|
+
try {
|
|
80
|
+
// net session succeeds only as admin
|
|
81
|
+
execSync("net session", { stdio: "ignore" });
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get the Startup folder shortcut path (per-user, never needs admin).
|
|
90
|
+
*/
|
|
91
|
+
function getStartupShortcutPath() {
|
|
92
|
+
const startupDir = resolve(
|
|
93
|
+
homedir(),
|
|
94
|
+
"AppData",
|
|
95
|
+
"Roaming",
|
|
96
|
+
"Microsoft",
|
|
97
|
+
"Windows",
|
|
98
|
+
"Start Menu",
|
|
99
|
+
"Programs",
|
|
100
|
+
"Startup",
|
|
101
|
+
);
|
|
102
|
+
return resolve(startupDir, `${TASK_NAME}.vbs`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build the command string for launching openfleet.
|
|
107
|
+
*/
|
|
108
|
+
function buildLaunchCommand({ daemon = true } = {}) {
|
|
109
|
+
const nodePath = getNodePath();
|
|
110
|
+
const cliPath = getCliPath();
|
|
111
|
+
const daemonFlag = daemon ? " --daemon" : "";
|
|
112
|
+
return `"${nodePath}" "${cliPath}"${daemonFlag}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function generateTaskSchedulerXml({ daemon = true } = {}) {
|
|
116
|
+
const nodePath = getNodePath();
|
|
117
|
+
const cliPath = getCliPath();
|
|
118
|
+
const args = daemon ? `"${cliPath}" --daemon` : `"${cliPath}"`;
|
|
119
|
+
|
|
120
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
121
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
122
|
+
<RegistrationInfo>
|
|
123
|
+
<Description>Auto-start openfleet AI orchestrator on login</Description>
|
|
124
|
+
<Author>Codex Monitor</Author>
|
|
125
|
+
<URI>\\${TASK_NAME}</URI>
|
|
126
|
+
</RegistrationInfo>
|
|
127
|
+
<Triggers>
|
|
128
|
+
<LogonTrigger>
|
|
129
|
+
<Enabled>true</Enabled>
|
|
130
|
+
</LogonTrigger>
|
|
131
|
+
</Triggers>
|
|
132
|
+
<Principals>
|
|
133
|
+
<Principal id="Author">
|
|
134
|
+
<LogonType>InteractiveToken</LogonType>
|
|
135
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
136
|
+
</Principal>
|
|
137
|
+
</Principals>
|
|
138
|
+
<Settings>
|
|
139
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
140
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
141
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
142
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
143
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
144
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
145
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
146
|
+
<Enabled>true</Enabled>
|
|
147
|
+
<Hidden>true</Hidden>
|
|
148
|
+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
149
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
150
|
+
<Priority>7</Priority>
|
|
151
|
+
<RestartOnFailure>
|
|
152
|
+
<Interval>PT5M</Interval>
|
|
153
|
+
<Count>3</Count>
|
|
154
|
+
</RestartOnFailure>
|
|
155
|
+
</Settings>
|
|
156
|
+
<Actions Context="Author">
|
|
157
|
+
<Exec>
|
|
158
|
+
<Command>"${nodePath}"</Command>
|
|
159
|
+
<Arguments>${args}</Arguments>
|
|
160
|
+
<WorkingDirectory>${getWorkingDirectory()}</WorkingDirectory>
|
|
161
|
+
</Exec>
|
|
162
|
+
</Actions>
|
|
163
|
+
</Task>`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Attempt to run a schtasks command with UAC elevation via PowerShell.
|
|
168
|
+
* Spawns an elevated PowerShell window and waits for completion.
|
|
169
|
+
* @param {string} schtasksArgs - The schtasks arguments (e.g. '/Create /TN ...')
|
|
170
|
+
* @returns {{ success: boolean, error?: string }}
|
|
171
|
+
*/
|
|
172
|
+
function runElevated(schtasksArgs) {
|
|
173
|
+
// Build a PowerShell script that runs schtasks elevated and signals result
|
|
174
|
+
const resultFile = resolve(__dirname, ".cache", "elevation-result.txt");
|
|
175
|
+
try {
|
|
176
|
+
if (existsSync(resultFile)) unlinkSync(resultFile);
|
|
177
|
+
} catch {
|
|
178
|
+
/* ok */
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The inner script: run schtasks, write exit code to result file
|
|
182
|
+
const innerScript = `
|
|
183
|
+
try {
|
|
184
|
+
$output = & schtasks ${schtasksArgs} 2>&1;
|
|
185
|
+
$output | Out-String | Set-Content -Path '${resultFile.replace(/\\/g, "\\\\")}' -Encoding UTF8;
|
|
186
|
+
if ($LASTEXITCODE -ne 0) { Add-Content -Path '${resultFile.replace(/\\/g, "\\\\")}' -Value "EXIT:$LASTEXITCODE" }
|
|
187
|
+
else { Add-Content -Path '${resultFile.replace(/\\/g, "\\\\")}' -Value "EXIT:0" }
|
|
188
|
+
} catch {
|
|
189
|
+
"ERROR: $($_.Exception.Message)" | Set-Content -Path '${resultFile.replace(/\\/g, "\\\\")}' -Encoding UTF8;
|
|
190
|
+
Add-Content -Path '${resultFile.replace(/\\/g, "\\\\")}' -Value "EXIT:1"
|
|
191
|
+
}
|
|
192
|
+
`.trim();
|
|
193
|
+
|
|
194
|
+
// Encode the script as base64 for -EncodedCommand
|
|
195
|
+
const encoded = Buffer.from(innerScript, "utf16le").toString("base64");
|
|
196
|
+
|
|
197
|
+
// Launch elevated PowerShell with -Verb RunAs (this triggers the UAC prompt)
|
|
198
|
+
const result = spawnSync(
|
|
199
|
+
"powershell.exe",
|
|
200
|
+
[
|
|
201
|
+
"-NoProfile",
|
|
202
|
+
"-Command",
|
|
203
|
+
`Start-Process powershell.exe -ArgumentList '-NoProfile','-NonInteractive','-EncodedCommand','${encoded}' -Verb RunAs -Wait`,
|
|
204
|
+
],
|
|
205
|
+
{
|
|
206
|
+
stdio: "pipe",
|
|
207
|
+
timeout: 60000, // 60s — UAC can take time
|
|
208
|
+
windowsHide: false, // Must show the UAC dialog
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// Read result file
|
|
213
|
+
try {
|
|
214
|
+
if (existsSync(resultFile)) {
|
|
215
|
+
const content = readFileSync(resultFile, "utf8").trim();
|
|
216
|
+
unlinkSync(resultFile);
|
|
217
|
+
const exitMatch = content.match(/EXIT:(\d+)/);
|
|
218
|
+
const exitCode = exitMatch ? parseInt(exitMatch[1], 10) : 1;
|
|
219
|
+
if (exitCode === 0) {
|
|
220
|
+
return { success: true };
|
|
221
|
+
}
|
|
222
|
+
const errorMsg =
|
|
223
|
+
content.replace(/EXIT:\d+/, "").trim() || "Elevated command failed";
|
|
224
|
+
return { success: false, error: errorMsg };
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
/* fall through */
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// No result file — user may have cancelled UAC
|
|
231
|
+
if (result.status !== 0 || result.error) {
|
|
232
|
+
return {
|
|
233
|
+
success: false,
|
|
234
|
+
error: result.error?.message || "UAC elevation was cancelled or failed",
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
success: false,
|
|
239
|
+
error: "Elevation result unknown — UAC may have been cancelled",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Install via Windows Startup folder (VBS wrapper). Never needs admin.
|
|
245
|
+
* Creates a small VBScript that launches node with openfleet.
|
|
246
|
+
*/
|
|
247
|
+
function installStartupFolder(options = {}) {
|
|
248
|
+
const shortcutPath = getStartupShortcutPath();
|
|
249
|
+
const launchCmd = buildLaunchCommand(options);
|
|
250
|
+
|
|
251
|
+
// VBScript wrapper to start hidden (no flash console window)
|
|
252
|
+
const vbsContent = `' Auto-generated by openfleet setup
|
|
253
|
+
' Starts openfleet on login via Startup folder
|
|
254
|
+
Set WshShell = CreateObject("WScript.Shell")
|
|
255
|
+
WshShell.CurrentDirectory = "${getWorkingDirectory().replace(/\\/g, "\\\\")}"
|
|
256
|
+
WshShell.Run ${JSON.stringify(launchCmd)}, 0, False
|
|
257
|
+
`;
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
mkdirSync(dirname(shortcutPath), { recursive: true });
|
|
261
|
+
writeFileSync(shortcutPath, vbsContent, "utf8");
|
|
262
|
+
return {
|
|
263
|
+
success: true,
|
|
264
|
+
method: "Startup folder",
|
|
265
|
+
name: basename(shortcutPath),
|
|
266
|
+
path: shortcutPath,
|
|
267
|
+
};
|
|
268
|
+
} catch (err) {
|
|
269
|
+
return {
|
|
270
|
+
success: false,
|
|
271
|
+
method: "Startup folder",
|
|
272
|
+
error: err.message,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function removeStartupFolder() {
|
|
278
|
+
const shortcutPath = getStartupShortcutPath();
|
|
279
|
+
try {
|
|
280
|
+
if (existsSync(shortcutPath)) {
|
|
281
|
+
unlinkSync(shortcutPath);
|
|
282
|
+
}
|
|
283
|
+
return { success: true, method: "Startup folder" };
|
|
284
|
+
} catch (err) {
|
|
285
|
+
return { success: false, method: "Startup folder", error: err.message };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function statusStartupFolder() {
|
|
290
|
+
const shortcutPath = getStartupShortcutPath();
|
|
291
|
+
return {
|
|
292
|
+
installed: existsSync(shortcutPath),
|
|
293
|
+
method: "Startup folder",
|
|
294
|
+
path: shortcutPath,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function installWindows(options = {}) {
|
|
299
|
+
const xmlContent = generateTaskSchedulerXml(options);
|
|
300
|
+
const tmpXml = resolve(__dirname, ".cache", `${TASK_NAME}.xml`);
|
|
301
|
+
|
|
302
|
+
mkdirSync(dirname(tmpXml), { recursive: true });
|
|
303
|
+
|
|
304
|
+
// Write as UTF-16 LE with BOM (required by schtasks)
|
|
305
|
+
const buf = Buffer.from("\ufeff" + xmlContent, "utf16le");
|
|
306
|
+
writeFileSync(tmpXml, buf);
|
|
307
|
+
|
|
308
|
+
// Strategy 1: Try schtasks directly (works if already admin or policy allows)
|
|
309
|
+
try {
|
|
310
|
+
try {
|
|
311
|
+
execSync(`schtasks /Delete /TN "${TASK_NAME}" /F`, { stdio: "ignore" });
|
|
312
|
+
} catch {
|
|
313
|
+
/* ok — task may not exist */
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
execSync(`schtasks /Create /TN "${TASK_NAME}" /XML "${tmpXml}" /F`, {
|
|
317
|
+
stdio: "pipe",
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
unlinkSync(tmpXml);
|
|
322
|
+
} catch {
|
|
323
|
+
/* ok */
|
|
324
|
+
}
|
|
325
|
+
return { success: true, method: "Task Scheduler", name: TASK_NAME };
|
|
326
|
+
} catch (directErr) {
|
|
327
|
+
const isAccessDenied =
|
|
328
|
+
directErr.message?.includes("Access is denied") ||
|
|
329
|
+
directErr.message?.includes("access is denied") ||
|
|
330
|
+
directErr.status === 1;
|
|
331
|
+
|
|
332
|
+
if (!isAccessDenied) {
|
|
333
|
+
try {
|
|
334
|
+
unlinkSync(tmpXml);
|
|
335
|
+
} catch {
|
|
336
|
+
/* ok */
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
success: false,
|
|
340
|
+
method: "Task Scheduler",
|
|
341
|
+
error: directErr.message,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Strategy 2: Elevate via UAC prompt
|
|
346
|
+
console.log(
|
|
347
|
+
" ℹ️ Admin access required — requesting elevation (UAC prompt)...",
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
// Delete + Create via elevated process
|
|
351
|
+
const deleteArgs = `/Delete /TN "${TASK_NAME}" /F`;
|
|
352
|
+
runElevated(deleteArgs); // ignore errors — may not exist
|
|
353
|
+
|
|
354
|
+
const createArgs = `/Create /TN "${TASK_NAME}" /XML "${tmpXml.replace(/\\/g, "\\\\")}" /F`;
|
|
355
|
+
const elevated = runElevated(createArgs);
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
unlinkSync(tmpXml);
|
|
359
|
+
} catch {
|
|
360
|
+
/* ok */
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (elevated.success) {
|
|
364
|
+
return {
|
|
365
|
+
success: true,
|
|
366
|
+
method: "Task Scheduler (elevated)",
|
|
367
|
+
name: TASK_NAME,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Strategy 3: Fall back to Startup folder (no admin needed)
|
|
372
|
+
console.log(
|
|
373
|
+
" ⚠️ Task Scheduler elevation failed — falling back to Startup folder.",
|
|
374
|
+
);
|
|
375
|
+
console.log(
|
|
376
|
+
" (Startup folder works without admin, but has no auto-restart on failure)",
|
|
377
|
+
);
|
|
378
|
+
return installStartupFolder(options);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function removeWindows() {
|
|
383
|
+
// Remove from both Task Scheduler and Startup folder (whichever exists)
|
|
384
|
+
const results = [];
|
|
385
|
+
|
|
386
|
+
// Try removing scheduled task
|
|
387
|
+
try {
|
|
388
|
+
execSync(`schtasks /Delete /TN "${TASK_NAME}" /F`, { stdio: "pipe" });
|
|
389
|
+
results.push({ success: true, method: "Task Scheduler" });
|
|
390
|
+
} catch (directErr) {
|
|
391
|
+
const isAccessDenied =
|
|
392
|
+
directErr.message?.includes("Access is denied") ||
|
|
393
|
+
directErr.message?.includes("access is denied");
|
|
394
|
+
|
|
395
|
+
if (isAccessDenied) {
|
|
396
|
+
console.log(
|
|
397
|
+
" ℹ️ Admin access required — requesting elevation (UAC prompt)...",
|
|
398
|
+
);
|
|
399
|
+
const elevated = runElevated(`/Delete /TN "${TASK_NAME}" /F`);
|
|
400
|
+
results.push({
|
|
401
|
+
success: elevated.success,
|
|
402
|
+
method: "Task Scheduler (elevated)",
|
|
403
|
+
error: elevated.success ? undefined : elevated.error,
|
|
404
|
+
});
|
|
405
|
+
} else {
|
|
406
|
+
// Task may simply not exist — that's fine
|
|
407
|
+
results.push({ success: true, method: "Task Scheduler" });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Also remove startup folder shortcut if present
|
|
412
|
+
const shortcutResult = removeStartupFolder();
|
|
413
|
+
if (
|
|
414
|
+
shortcutResult.success &&
|
|
415
|
+
existsSync(getStartupShortcutPath()) === false
|
|
416
|
+
) {
|
|
417
|
+
results.push(shortcutResult);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Return combined result
|
|
421
|
+
const anySuccess = results.some((r) => r.success);
|
|
422
|
+
return {
|
|
423
|
+
success: anySuccess,
|
|
424
|
+
method: results.map((r) => r.method).join(" + "),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function statusWindows() {
|
|
429
|
+
// Check Task Scheduler first
|
|
430
|
+
try {
|
|
431
|
+
const output = execSync(`schtasks /Query /TN "${TASK_NAME}" /FO CSV /NH`, {
|
|
432
|
+
encoding: "utf8",
|
|
433
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
434
|
+
}).trim();
|
|
435
|
+
if (output && output.includes(TASK_NAME)) {
|
|
436
|
+
const enabled = output.toLowerCase().includes("ready");
|
|
437
|
+
return {
|
|
438
|
+
installed: true,
|
|
439
|
+
enabled,
|
|
440
|
+
method: "Task Scheduler",
|
|
441
|
+
name: TASK_NAME,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
} catch {
|
|
445
|
+
/* not in Task Scheduler — check Startup folder */
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Check Startup folder fallback
|
|
449
|
+
const folderStatus = statusStartupFolder();
|
|
450
|
+
if (folderStatus.installed) {
|
|
451
|
+
return { ...folderStatus, enabled: true };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return { installed: false, method: "Task Scheduler" };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── macOS: launchd ───────────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
function getLaunchdPlistPath() {
|
|
460
|
+
return resolve(
|
|
461
|
+
homedir(),
|
|
462
|
+
"Library",
|
|
463
|
+
"LaunchAgents",
|
|
464
|
+
`${SERVICE_LABEL}.plist`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function generateLaunchdPlist({ daemon = true } = {}) {
|
|
469
|
+
const nodePath = getNodePath();
|
|
470
|
+
const cliPath = getCliPath();
|
|
471
|
+
const logDir = getLogDir();
|
|
472
|
+
const home = homedir();
|
|
473
|
+
const args = daemon ? [nodePath, cliPath, "--daemon"] : [nodePath, cliPath];
|
|
474
|
+
|
|
475
|
+
const argsXml = args.map((a) => ` <string>${a}</string>`).join("\n");
|
|
476
|
+
|
|
477
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
478
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
479
|
+
<plist version="1.0">
|
|
480
|
+
<dict>
|
|
481
|
+
<key>Label</key>
|
|
482
|
+
<string>${SERVICE_LABEL}</string>
|
|
483
|
+
<key>ProgramArguments</key>
|
|
484
|
+
<array>
|
|
485
|
+
${argsXml}
|
|
486
|
+
</array>
|
|
487
|
+
<key>WorkingDirectory</key>
|
|
488
|
+
<string>${getWorkingDirectory()}</string>
|
|
489
|
+
<key>RunAtLoad</key>
|
|
490
|
+
<true/>
|
|
491
|
+
<key>KeepAlive</key>
|
|
492
|
+
<dict>
|
|
493
|
+
<key>SuccessfulExit</key>
|
|
494
|
+
<false/>
|
|
495
|
+
</dict>
|
|
496
|
+
<key>ThrottleInterval</key>
|
|
497
|
+
<integer>30</integer>
|
|
498
|
+
<key>EnvironmentVariables</key>
|
|
499
|
+
<dict>
|
|
500
|
+
<key>PATH</key>
|
|
501
|
+
<string>${home}/.local/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
502
|
+
<key>HOME</key>
|
|
503
|
+
<string>${home}</string>
|
|
504
|
+
</dict>
|
|
505
|
+
<key>StandardOutPath</key>
|
|
506
|
+
<string>${logDir}/startup.log</string>
|
|
507
|
+
<key>StandardErrorPath</key>
|
|
508
|
+
<string>${logDir}/startup.error.log</string>
|
|
509
|
+
</dict>
|
|
510
|
+
</plist>`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function installMacOS(options = {}) {
|
|
514
|
+
const plistPath = getLaunchdPlistPath();
|
|
515
|
+
const plistContent = generateLaunchdPlist(options);
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
// Unload existing agent if present
|
|
519
|
+
try {
|
|
520
|
+
execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" });
|
|
521
|
+
} catch {
|
|
522
|
+
/* ok */
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
mkdirSync(dirname(plistPath), { recursive: true });
|
|
526
|
+
writeFileSync(plistPath, plistContent, "utf8");
|
|
527
|
+
execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
success: true,
|
|
531
|
+
method: "launchd",
|
|
532
|
+
name: SERVICE_LABEL,
|
|
533
|
+
path: plistPath,
|
|
534
|
+
};
|
|
535
|
+
} catch (err) {
|
|
536
|
+
const isPermission =
|
|
537
|
+
err.message?.includes("Permission denied") ||
|
|
538
|
+
err.message?.includes("Operation not permitted") ||
|
|
539
|
+
err.message?.includes("EACCES");
|
|
540
|
+
|
|
541
|
+
if (!isPermission) {
|
|
542
|
+
return { success: false, method: "launchd", error: err.message };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Try with sudo — prompts for password in terminal via osascript or direct sudo
|
|
546
|
+
console.log(" ℹ️ Permission required — requesting sudo access...");
|
|
547
|
+
try {
|
|
548
|
+
// Write plist to temp location first
|
|
549
|
+
const tmpPlist = resolve(__dirname, ".cache", `${SERVICE_LABEL}.plist`);
|
|
550
|
+
mkdirSync(dirname(tmpPlist), { recursive: true });
|
|
551
|
+
writeFileSync(tmpPlist, plistContent, "utf8");
|
|
552
|
+
|
|
553
|
+
// Use osascript to prompt for admin credentials (shows macOS auth dialog)
|
|
554
|
+
const escapedPlistPath = plistPath.replace(/'/g, "'\''");
|
|
555
|
+
const escapedTmpPlist = tmpPlist.replace(/'/g, "'\''");
|
|
556
|
+
const script = [
|
|
557
|
+
`do shell script "`,
|
|
558
|
+
`mkdir -p '${dirname(escapedPlistPath)}' && `,
|
|
559
|
+
`cp '${escapedTmpPlist}' '${escapedPlistPath}' && `,
|
|
560
|
+
`launchctl load '${escapedPlistPath}'`,
|
|
561
|
+
`" with administrator privileges`,
|
|
562
|
+
].join("");
|
|
563
|
+
|
|
564
|
+
execSync(`osascript -e '${script.replace(/'/g, "'\''")}'`, {
|
|
565
|
+
stdio: "pipe",
|
|
566
|
+
timeout: 60000,
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
try {
|
|
570
|
+
unlinkSync(tmpPlist);
|
|
571
|
+
} catch {
|
|
572
|
+
/* ok */
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return {
|
|
576
|
+
success: true,
|
|
577
|
+
method: "launchd (elevated)",
|
|
578
|
+
name: SERVICE_LABEL,
|
|
579
|
+
path: plistPath,
|
|
580
|
+
};
|
|
581
|
+
} catch (sudoErr) {
|
|
582
|
+
return {
|
|
583
|
+
success: false,
|
|
584
|
+
method: "launchd",
|
|
585
|
+
error:
|
|
586
|
+
`Elevation failed: ${sudoErr.message}. You can install manually:\n` +
|
|
587
|
+
` sudo cp <plist> ${plistPath}\n` +
|
|
588
|
+
` launchctl load ${plistPath}`,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function removeMacOS() {
|
|
595
|
+
const plistPath = getLaunchdPlistPath();
|
|
596
|
+
try {
|
|
597
|
+
try {
|
|
598
|
+
execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" });
|
|
599
|
+
} catch {
|
|
600
|
+
/* ok */
|
|
601
|
+
}
|
|
602
|
+
if (existsSync(plistPath)) {
|
|
603
|
+
unlinkSync(plistPath);
|
|
604
|
+
}
|
|
605
|
+
return { success: true, method: "launchd" };
|
|
606
|
+
} catch (err) {
|
|
607
|
+
const isPermission =
|
|
608
|
+
err.message?.includes("Permission denied") ||
|
|
609
|
+
err.message?.includes("Operation not permitted") ||
|
|
610
|
+
err.message?.includes("EACCES");
|
|
611
|
+
|
|
612
|
+
if (!isPermission) {
|
|
613
|
+
return { success: false, method: "launchd", error: err.message };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Elevate via osascript
|
|
617
|
+
console.log(" ℹ️ Permission required — requesting sudo access...");
|
|
618
|
+
try {
|
|
619
|
+
const escapedPlistPath = plistPath.replace(/'/g, "'\\''");
|
|
620
|
+
const script = `do shell script "launchctl unload '${escapedPlistPath}' 2>/dev/null; rm -f '${escapedPlistPath}'" with administrator privileges`;
|
|
621
|
+
execSync(`osascript -e '${script.replace(/'/g, "'\\''")}'`, {
|
|
622
|
+
stdio: "pipe",
|
|
623
|
+
timeout: 60000,
|
|
624
|
+
});
|
|
625
|
+
return { success: true, method: "launchd (elevated)" };
|
|
626
|
+
} catch (sudoErr) {
|
|
627
|
+
return {
|
|
628
|
+
success: false,
|
|
629
|
+
method: "launchd",
|
|
630
|
+
error: `Elevation failed: ${sudoErr.message}`,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function statusMacOS() {
|
|
637
|
+
const plistPath = getLaunchdPlistPath();
|
|
638
|
+
if (!existsSync(plistPath)) {
|
|
639
|
+
return { installed: false, method: "launchd" };
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
const output = execSync(`launchctl list`, {
|
|
643
|
+
encoding: "utf8",
|
|
644
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
645
|
+
});
|
|
646
|
+
const running = output.includes(SERVICE_LABEL);
|
|
647
|
+
return {
|
|
648
|
+
installed: true,
|
|
649
|
+
enabled: true,
|
|
650
|
+
running,
|
|
651
|
+
method: "launchd",
|
|
652
|
+
name: SERVICE_LABEL,
|
|
653
|
+
path: plistPath,
|
|
654
|
+
};
|
|
655
|
+
} catch {
|
|
656
|
+
return {
|
|
657
|
+
installed: true,
|
|
658
|
+
enabled: true,
|
|
659
|
+
method: "launchd",
|
|
660
|
+
path: plistPath,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ── Linux: systemd user unit ─────────────────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Check if systemd user session (--user) is available.
|
|
669
|
+
* Not all Linux environments support it (e.g., WSL1, containers, no logind).
|
|
670
|
+
*/
|
|
671
|
+
function hasSystemdUser() {
|
|
672
|
+
try {
|
|
673
|
+
execSync("systemctl --user --no-pager status", {
|
|
674
|
+
stdio: "ignore",
|
|
675
|
+
timeout: 5000,
|
|
676
|
+
});
|
|
677
|
+
return true;
|
|
678
|
+
} catch {
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Get the crontab-based fallback marker.
|
|
685
|
+
*/
|
|
686
|
+
function getCronMarker() {
|
|
687
|
+
return `# openfleet-autostart`;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function getSystemdUnitPath() {
|
|
691
|
+
return resolve(homedir(), ".config", "systemd", "user", SYSTEMD_UNIT);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function generateSystemdUnit({ daemon = false } = {}) {
|
|
695
|
+
// systemd handles daemonization — we do NOT use --daemon flag here
|
|
696
|
+
const nodePath = getNodePath();
|
|
697
|
+
const cliPath = getCliPath();
|
|
698
|
+
const logDir = getLogDir();
|
|
699
|
+
|
|
700
|
+
return `[Unit]
|
|
701
|
+
Description=openfleet — AI Orchestrator Supervisor
|
|
702
|
+
Documentation=https://www.npmjs.com/package/@virtengine/openfleet
|
|
703
|
+
After=network-online.target
|
|
704
|
+
Wants=network-online.target
|
|
705
|
+
|
|
706
|
+
[Service]
|
|
707
|
+
Type=simple
|
|
708
|
+
ExecStart=${nodePath} ${cliPath}
|
|
709
|
+
WorkingDirectory=${getWorkingDirectory()}
|
|
710
|
+
Restart=on-failure
|
|
711
|
+
RestartSec=30
|
|
712
|
+
StandardOutput=append:${logDir}/startup.log
|
|
713
|
+
StandardError=append:${logDir}/startup.error.log
|
|
714
|
+
Environment=NODE_ENV=production
|
|
715
|
+
Environment=HOME=${homedir()}
|
|
716
|
+
Environment=PATH=${homedir()}/.local/bin:/usr/local/bin:/usr/bin:/bin
|
|
717
|
+
|
|
718
|
+
[Install]
|
|
719
|
+
WantedBy=default.target
|
|
720
|
+
`;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function installLinux(options = {}) {
|
|
724
|
+
const unitPath = getSystemdUnitPath();
|
|
725
|
+
// systemd handles service lifecycle — never pass --daemon
|
|
726
|
+
const unitContent = generateSystemdUnit({ ...options, daemon: false });
|
|
727
|
+
|
|
728
|
+
// Strategy 1: systemd --user (preferred, no root needed)
|
|
729
|
+
if (hasSystemdUser()) {
|
|
730
|
+
try {
|
|
731
|
+
mkdirSync(dirname(unitPath), { recursive: true });
|
|
732
|
+
writeFileSync(unitPath, unitContent, "utf8");
|
|
733
|
+
|
|
734
|
+
execSync("systemctl --user daemon-reload", { stdio: "pipe" });
|
|
735
|
+
execSync(`systemctl --user enable ${SYSTEMD_UNIT}`, { stdio: "pipe" });
|
|
736
|
+
execSync(`systemctl --user start ${SYSTEMD_UNIT}`, { stdio: "pipe" });
|
|
737
|
+
|
|
738
|
+
return {
|
|
739
|
+
success: true,
|
|
740
|
+
method: "systemd",
|
|
741
|
+
name: SYSTEMD_UNIT,
|
|
742
|
+
path: unitPath,
|
|
743
|
+
};
|
|
744
|
+
} catch (err) {
|
|
745
|
+
const isPermission =
|
|
746
|
+
err.message?.includes("Permission denied") ||
|
|
747
|
+
err.message?.includes("EACCES") ||
|
|
748
|
+
err.message?.includes("Access denied");
|
|
749
|
+
|
|
750
|
+
if (!isPermission) {
|
|
751
|
+
return { success: false, method: "systemd", error: err.message };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Try with sudo for the systemctl commands (unit file is user-space)
|
|
755
|
+
console.log(" ℹ️ Permission required — trying sudo...");
|
|
756
|
+
try {
|
|
757
|
+
// The unit file write doesn't need sudo (it's in ~/.config)
|
|
758
|
+
// but systemctl might if the session isn't fully initialized
|
|
759
|
+
execSync(`sudo systemctl --user daemon-reload`, { stdio: "inherit" });
|
|
760
|
+
execSync(`sudo systemctl --user enable ${SYSTEMD_UNIT}`, {
|
|
761
|
+
stdio: "inherit",
|
|
762
|
+
});
|
|
763
|
+
execSync(`sudo systemctl --user start ${SYSTEMD_UNIT}`, {
|
|
764
|
+
stdio: "inherit",
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
return {
|
|
768
|
+
success: true,
|
|
769
|
+
method: "systemd (sudo)",
|
|
770
|
+
name: SYSTEMD_UNIT,
|
|
771
|
+
path: unitPath,
|
|
772
|
+
};
|
|
773
|
+
} catch (sudoErr) {
|
|
774
|
+
console.log(
|
|
775
|
+
" ⚠️ systemd with sudo failed — falling back to crontab.",
|
|
776
|
+
);
|
|
777
|
+
// Fall through to crontab
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
} else {
|
|
781
|
+
console.log(" ℹ️ systemd user session not available — using crontab.");
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Strategy 2: crontab @reboot fallback (works everywhere, no root needed)
|
|
785
|
+
return installCrontab(options);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Install via crontab @reboot entry. Works on any Linux without root.
|
|
790
|
+
*/
|
|
791
|
+
function installCrontab(options = {}) {
|
|
792
|
+
const nodePath = getNodePath();
|
|
793
|
+
const cliPath = getCliPath();
|
|
794
|
+
const logDir = getLogDir();
|
|
795
|
+
const marker = getCronMarker();
|
|
796
|
+
const daemon = options.daemon !== false ? " --daemon" : "";
|
|
797
|
+
const cronLine = `@reboot cd ${getWorkingDirectory()} && ${nodePath} ${cliPath}${daemon} >> ${logDir}/startup.log 2>> ${logDir}/startup.error.log ${marker}`;
|
|
798
|
+
|
|
799
|
+
try {
|
|
800
|
+
// Get current crontab
|
|
801
|
+
let existing = "";
|
|
802
|
+
try {
|
|
803
|
+
existing = execSync("crontab -l", {
|
|
804
|
+
encoding: "utf8",
|
|
805
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
806
|
+
});
|
|
807
|
+
} catch {
|
|
808
|
+
/* no crontab yet — that's fine */
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Remove any existing openfleet entry
|
|
812
|
+
const lines = existing.split("\n").filter((l) => !l.includes(marker));
|
|
813
|
+
lines.push(cronLine);
|
|
814
|
+
|
|
815
|
+
// Write new crontab
|
|
816
|
+
const newCrontab =
|
|
817
|
+
lines
|
|
818
|
+
.join("\n")
|
|
819
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
820
|
+
.trim() + "\n";
|
|
821
|
+
execSync("crontab -", {
|
|
822
|
+
input: newCrontab,
|
|
823
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
return {
|
|
827
|
+
success: true,
|
|
828
|
+
method: "crontab @reboot",
|
|
829
|
+
name: "crontab",
|
|
830
|
+
};
|
|
831
|
+
} catch (err) {
|
|
832
|
+
return {
|
|
833
|
+
success: false,
|
|
834
|
+
method: "crontab",
|
|
835
|
+
error: err.message,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function removeLinux() {
|
|
841
|
+
const results = [];
|
|
842
|
+
|
|
843
|
+
// Remove systemd unit if present
|
|
844
|
+
const unitPath = getSystemdUnitPath();
|
|
845
|
+
if (existsSync(unitPath)) {
|
|
846
|
+
try {
|
|
847
|
+
try {
|
|
848
|
+
execSync(`systemctl --user stop ${SYSTEMD_UNIT}`, { stdio: "ignore" });
|
|
849
|
+
} catch {
|
|
850
|
+
/* ok */
|
|
851
|
+
}
|
|
852
|
+
try {
|
|
853
|
+
execSync(`systemctl --user disable ${SYSTEMD_UNIT}`, {
|
|
854
|
+
stdio: "ignore",
|
|
855
|
+
});
|
|
856
|
+
} catch {
|
|
857
|
+
/* ok */
|
|
858
|
+
}
|
|
859
|
+
unlinkSync(unitPath);
|
|
860
|
+
execSync("systemctl --user daemon-reload", { stdio: "ignore" });
|
|
861
|
+
results.push({ success: true, method: "systemd" });
|
|
862
|
+
} catch (err) {
|
|
863
|
+
const isPermission =
|
|
864
|
+
err.message?.includes("Permission denied") ||
|
|
865
|
+
err.message?.includes("EACCES");
|
|
866
|
+
|
|
867
|
+
if (isPermission) {
|
|
868
|
+
console.log(" ℹ️ Permission required — trying sudo...");
|
|
869
|
+
try {
|
|
870
|
+
execSync(`sudo systemctl --user stop ${SYSTEMD_UNIT}`, {
|
|
871
|
+
stdio: "inherit",
|
|
872
|
+
});
|
|
873
|
+
execSync(`sudo systemctl --user disable ${SYSTEMD_UNIT}`, {
|
|
874
|
+
stdio: "inherit",
|
|
875
|
+
});
|
|
876
|
+
if (existsSync(unitPath)) unlinkSync(unitPath);
|
|
877
|
+
execSync("sudo systemctl --user daemon-reload", { stdio: "inherit" });
|
|
878
|
+
results.push({ success: true, method: "systemd (sudo)" });
|
|
879
|
+
} catch (sudoErr) {
|
|
880
|
+
results.push({
|
|
881
|
+
success: false,
|
|
882
|
+
method: "systemd",
|
|
883
|
+
error: sudoErr.message,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
} else {
|
|
887
|
+
results.push({ success: false, method: "systemd", error: err.message });
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Remove crontab entry if present
|
|
893
|
+
const marker = getCronMarker();
|
|
894
|
+
try {
|
|
895
|
+
const existing = execSync("crontab -l", {
|
|
896
|
+
encoding: "utf8",
|
|
897
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
898
|
+
});
|
|
899
|
+
if (existing.includes(marker)) {
|
|
900
|
+
const filtered =
|
|
901
|
+
existing
|
|
902
|
+
.split("\n")
|
|
903
|
+
.filter((l) => !l.includes(marker))
|
|
904
|
+
.join("\n")
|
|
905
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
906
|
+
.trim() + "\n";
|
|
907
|
+
execSync("crontab -", {
|
|
908
|
+
input: filtered,
|
|
909
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
910
|
+
});
|
|
911
|
+
results.push({ success: true, method: "crontab" });
|
|
912
|
+
}
|
|
913
|
+
} catch {
|
|
914
|
+
/* no crontab — fine */
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (results.length === 0) {
|
|
918
|
+
return { success: true, method: "none (nothing to remove)" };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const anySuccess = results.some((r) => r.success);
|
|
922
|
+
return {
|
|
923
|
+
success: anySuccess,
|
|
924
|
+
method: results.map((r) => r.method).join(" + "),
|
|
925
|
+
error: anySuccess
|
|
926
|
+
? undefined
|
|
927
|
+
: results
|
|
928
|
+
.map((r) => r.error)
|
|
929
|
+
.filter(Boolean)
|
|
930
|
+
.join("; "),
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function statusLinux() {
|
|
935
|
+
// Check systemd first
|
|
936
|
+
const unitPath = getSystemdUnitPath();
|
|
937
|
+
if (existsSync(unitPath)) {
|
|
938
|
+
try {
|
|
939
|
+
const output = execSync(`systemctl --user is-active ${SYSTEMD_UNIT}`, {
|
|
940
|
+
encoding: "utf8",
|
|
941
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
942
|
+
}).trim();
|
|
943
|
+
return {
|
|
944
|
+
installed: true,
|
|
945
|
+
enabled: true,
|
|
946
|
+
running: output === "active",
|
|
947
|
+
method: "systemd",
|
|
948
|
+
name: SYSTEMD_UNIT,
|
|
949
|
+
path: unitPath,
|
|
950
|
+
};
|
|
951
|
+
} catch {
|
|
952
|
+
return {
|
|
953
|
+
installed: true,
|
|
954
|
+
enabled: true,
|
|
955
|
+
running: false,
|
|
956
|
+
method: "systemd",
|
|
957
|
+
name: SYSTEMD_UNIT,
|
|
958
|
+
path: unitPath,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Check crontab fallback
|
|
964
|
+
const marker = getCronMarker();
|
|
965
|
+
try {
|
|
966
|
+
const existing = execSync("crontab -l", {
|
|
967
|
+
encoding: "utf8",
|
|
968
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
969
|
+
});
|
|
970
|
+
if (existing.includes(marker)) {
|
|
971
|
+
return {
|
|
972
|
+
installed: true,
|
|
973
|
+
enabled: true,
|
|
974
|
+
method: "crontab @reboot",
|
|
975
|
+
name: "crontab",
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
} catch {
|
|
979
|
+
/* no crontab */
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return { installed: false, method: "systemd" };
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Install openfleet as a startup service.
|
|
989
|
+
* @param {{ daemon?: boolean }} options Whether to start in daemon mode (default: true)
|
|
990
|
+
* @returns {Promise<{ success: boolean, method: string, error?: string, name?: string, path?: string }>}
|
|
991
|
+
*/
|
|
992
|
+
export async function installStartupService(options = {}) {
|
|
993
|
+
const opts = { daemon: true, ...options };
|
|
994
|
+
const platform = getPlatform();
|
|
995
|
+
|
|
996
|
+
switch (platform) {
|
|
997
|
+
case "windows":
|
|
998
|
+
return installWindows(opts);
|
|
999
|
+
case "macos":
|
|
1000
|
+
return installMacOS(opts);
|
|
1001
|
+
case "linux":
|
|
1002
|
+
return installLinux(opts);
|
|
1003
|
+
default:
|
|
1004
|
+
return {
|
|
1005
|
+
success: false,
|
|
1006
|
+
method: "none",
|
|
1007
|
+
error: `Unsupported platform: ${process.platform}`,
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Remove openfleet from startup services.
|
|
1014
|
+
* @returns {Promise<{ success: boolean, method: string, error?: string }>}
|
|
1015
|
+
*/
|
|
1016
|
+
export async function removeStartupService() {
|
|
1017
|
+
const platform = getPlatform();
|
|
1018
|
+
|
|
1019
|
+
switch (platform) {
|
|
1020
|
+
case "windows":
|
|
1021
|
+
return removeWindows();
|
|
1022
|
+
case "macos":
|
|
1023
|
+
return removeMacOS();
|
|
1024
|
+
case "linux":
|
|
1025
|
+
return removeLinux();
|
|
1026
|
+
default:
|
|
1027
|
+
return {
|
|
1028
|
+
success: false,
|
|
1029
|
+
method: "none",
|
|
1030
|
+
error: `Unsupported platform: ${process.platform}`,
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Get the current startup service status.
|
|
1037
|
+
* @returns {{ installed: boolean, enabled?: boolean, running?: boolean, method: string, name?: string, path?: string }}
|
|
1038
|
+
*/
|
|
1039
|
+
export function getStartupStatus() {
|
|
1040
|
+
const platform = getPlatform();
|
|
1041
|
+
|
|
1042
|
+
switch (platform) {
|
|
1043
|
+
case "windows":
|
|
1044
|
+
return statusWindows();
|
|
1045
|
+
case "macos":
|
|
1046
|
+
return statusMacOS();
|
|
1047
|
+
case "linux":
|
|
1048
|
+
return statusLinux();
|
|
1049
|
+
default:
|
|
1050
|
+
return { installed: false, method: "none" };
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Get human-readable platform method name.
|
|
1056
|
+
* @returns {string}
|
|
1057
|
+
*/
|
|
1058
|
+
export function getStartupMethodName() {
|
|
1059
|
+
const platform = getPlatform();
|
|
1060
|
+
switch (platform) {
|
|
1061
|
+
case "windows":
|
|
1062
|
+
return "Windows Task Scheduler";
|
|
1063
|
+
case "macos":
|
|
1064
|
+
return "macOS launchd";
|
|
1065
|
+
case "linux":
|
|
1066
|
+
return "systemd user service";
|
|
1067
|
+
default:
|
|
1068
|
+
return "unsupported";
|
|
1069
|
+
}
|
|
1070
|
+
}
|