@gecho-ai/gecho-bridge 1.1.38-beta.0 → 1.1.38

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@gecho-ai/gecho-bridge",
3
- "version": "1.1.38-beta.0",
3
+ "version": "1.1.38",
4
4
  "description": "Browser research tools for OpenClaw via the Gecho Chrome Extension"
5
5
  }
package/README.md CHANGED
@@ -281,6 +281,8 @@ If installation is successful, you should see `Status: loaded` and `MCP servers:
281
281
  ### 2. Note about the local background service
282
282
  - Gecho Bridge automatically starts a local service on demand so the MCP client can talk to the browser extension.
283
283
  - This service only listens on `127.0.0.1` and is expected to stay available while you use the plugin.
284
+ - You can also manage it independently with `npm run server:start`, `npm run server:status`, and `npm run server:stop`.
285
+ - To run it after user login, use `npx gecho-bridge-server install`; remove the startup entry with `npx gecho-bridge-server uninstall`.
284
286
  - If Chrome or the extension was restarted and requests start failing, first run `openclaw gateway restart`, then try again.
285
287
 
286
288
  ### 3. Error: Extension not connected
@@ -329,6 +331,8 @@ npm run server
329
331
  - **Client layer** (`mcp-client.js`): The standard MCP STDIO integration layer, responsible for communicating with clients such as OpenClaw, Hermes, and Trae, and declaring the Tools specification.
330
332
  - **Service layer** (`server.js`): The local resident service layer, responsible for communicating with the browser extension via WebSocket and executing the actual scraping and persistence. (Supports a Lazy Start mechanism and launches only when needed.)
331
333
 
334
+ See [`docs/scheduled-flywheel-bridge.md`](docs/scheduled-flywheel-bridge.md) for scheduled flywheel APIs, standalone Bridge operation, and user-level login startup.
335
+
332
336
  ## License
333
337
 
334
338
  MIT
package/README.zh.md CHANGED
@@ -286,6 +286,8 @@ openclaw plugins info @gecho-ai/gecho-bridge-bundle
286
286
  ### 2. 关于本地后台服务的说明
287
287
  - Gecho Bridge 会在需要时自动拉起本地服务,用来让 MCP 客户端和浏览器扩展通信。
288
288
  - 这个服务只监听 `127.0.0.1`,在你使用插件期间保持可用属于正常行为。
289
+ - 也可以独立管理服务:`npm run server:start`、`npm run server:status`、`npm run server:stop`。
290
+ - 如需用户登录后自动运行,可执行 `npx gecho-bridge-server install`;卸载启动项执行 `npx gecho-bridge-server uninstall`。
289
291
  - 如果你重启了 Chrome 或扩展,随后请求开始失败,先执行一次 `openclaw gateway restart`,再重试。
290
292
 
291
293
  ### 3. 报错:提示扩展未连接
@@ -334,6 +336,8 @@ npm run server
334
336
  - **Client 层** (`mcp-client.js`):标准 MCP STDIO 接入层,负责与 OpenClaw/Hermes/Trae 等客户端通信,并声明 Tools 规范。
335
337
  - **Service 层** (`server.js`):本地常驻服务层,负责与浏览器扩展通过 WebSocket 通信,执行实际抓取与落盘。(支持 Lazy Start 机制,仅在需要时自动拉起)。
336
338
 
339
+ 自动飞轮的定时任务接口、Bridge 独立运行和用户级开机启动说明见 [`docs/scheduled-flywheel-bridge.md`](docs/scheduled-flywheel-bridge.md)。
340
+
337
341
  ## License
338
342
 
339
343
  MIT
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const http = require("node:http");
6
+ const path = require("node:path");
7
+ const { spawn } = require("node:child_process");
8
+ const packageJson = require("./package.json");
9
+ const { getDefaultDataDir } = require("./data-dir");
10
+ const {
11
+ getStartupStatus,
12
+ installStartup,
13
+ uninstallStartup
14
+ } = require("./bridge-startup");
15
+
16
+ const HTTP_PORT = Number(process.env.GECHO_HTTP_PORT || 18793);
17
+ const SERVICE_BASE_URL = `http://127.0.0.1:${HTTP_PORT}`;
18
+ const SERVICE_PATH = path.join(__dirname, "server.js");
19
+ const SERVICE_PROTOCOL_VERSION = 2;
20
+ const START_TIMEOUT_MS = Math.max(1000, Number(process.env.GECHO_SERVICE_START_TIMEOUT_MS || 10000));
21
+ const POLL_INTERVAL_MS = 200;
22
+
23
+ function requestJson(requestPath, method = "GET") {
24
+ return new Promise((resolve) => {
25
+ const request = http.request(`${SERVICE_BASE_URL}${requestPath}`, { method }, (response) => {
26
+ let body = "";
27
+ response.setEncoding("utf8");
28
+ response.on("data", (chunk) => { body += chunk; });
29
+ response.on("end", () => {
30
+ let parsed = {};
31
+ try { parsed = JSON.parse(body || "{}"); } catch (_error) {}
32
+ resolve({
33
+ ok: response.statusCode >= 200 && response.statusCode < 300,
34
+ statusCode: response.statusCode || 0,
35
+ body: parsed
36
+ });
37
+ });
38
+ });
39
+ request.on("error", () => resolve({ ok: false, statusCode: 0, body: {} }));
40
+ request.setTimeout(800, () => {
41
+ request.destroy();
42
+ resolve({ ok: false, statusCode: 0, body: {} });
43
+ });
44
+ request.end();
45
+ });
46
+ }
47
+
48
+ function expectedDataDir() {
49
+ return path.resolve(process.env.GECHO_DATA_DIR || getDefaultDataDir());
50
+ }
51
+
52
+ function isCompatibleService(info) {
53
+ if (!info || info.status !== "ok") return false;
54
+ if (Number(info.serviceProtocolVersion || 0) !== SERVICE_PROTOCOL_VERSION) return false;
55
+ if (info.packageVersion && info.packageVersion !== packageJson.version) return false;
56
+ return !info.dataDir || path.resolve(String(info.dataDir)) === expectedDataDir();
57
+ }
58
+
59
+ async function getServiceInfo() {
60
+ const response = await requestJson("/ping");
61
+ return response.ok ? response.body : null;
62
+ }
63
+
64
+ async function waitForService({ running = true, timeoutMs = START_TIMEOUT_MS } = {}) {
65
+ const startedAt = Date.now();
66
+ while (Date.now() - startedAt <= timeoutMs) {
67
+ const info = await getServiceInfo();
68
+ if (running ? isCompatibleService(info) : !info) return info;
69
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
70
+ }
71
+ return null;
72
+ }
73
+
74
+ async function stopService() {
75
+ const info = await getServiceInfo();
76
+ if (!info) return { status: "not running" };
77
+
78
+ await requestJson("/shutdown", "POST");
79
+ const stopped = await waitForService({ running: false });
80
+ if (stopped === null && Number.isInteger(Number(info.pid)) && Number(info.pid) > 0) {
81
+ try { process.kill(Number(info.pid), "SIGTERM"); } catch (_error) {}
82
+ await waitForService({ running: false, timeoutMs: 3000 });
83
+ }
84
+ return { status: "stopped", pid: info.pid || null };
85
+ }
86
+
87
+ async function startService() {
88
+ const current = await getServiceInfo();
89
+ if (isCompatibleService(current)) {
90
+ return { status: "already running", pid: current.pid || null };
91
+ }
92
+ if (current) await stopService();
93
+
94
+ const child = spawn(process.execPath, [SERVICE_PATH], {
95
+ cwd: __dirname,
96
+ env: process.env,
97
+ detached: true,
98
+ stdio: "ignore",
99
+ windowsHide: true
100
+ });
101
+ child.unref();
102
+ const started = await waitForService();
103
+ if (!started) {
104
+ const error = new Error("Bridge service did not become ready");
105
+ error.code = "BRIDGE_SERVICE_START_FAILED";
106
+ throw error;
107
+ }
108
+ return { status: "started", pid: started.pid || child.pid || null };
109
+ }
110
+
111
+ async function printStatus() {
112
+ const info = await getServiceInfo();
113
+ if (info) {
114
+ process.stdout.write(`${JSON.stringify(info)}\n`);
115
+ return true;
116
+ }
117
+ process.stdout.write(`${JSON.stringify({ status: "not running", url: SERVICE_BASE_URL })}\n`);
118
+ return false;
119
+ }
120
+
121
+ function printUsage() {
122
+ process.stdout.write([
123
+ "Usage: gecho-bridge-server <start|status|stop|restart|install|uninstall|startup-status>",
124
+ "",
125
+ " start Start the standalone local Bridge service",
126
+ " status Print Bridge health and scheduler status",
127
+ " stop Stop the local Bridge service",
128
+ " restart Restart the local Bridge service",
129
+ " install Install a user-level login startup entry",
130
+ " uninstall Remove the user-level login startup entry",
131
+ " startup-status Print login startup configuration status",
132
+ ""
133
+ ].join("\n"));
134
+ }
135
+
136
+ async function main(argv = process.argv.slice(2)) {
137
+ const command = String(argv[0] || "start").trim().toLowerCase();
138
+ if (command === "status") {
139
+ const running = await printStatus();
140
+ process.exitCode = running ? 0 : 1;
141
+ return;
142
+ }
143
+ if (command === "start") {
144
+ process.stdout.write(`${JSON.stringify(await startService())}\n`);
145
+ return;
146
+ }
147
+ if (command === "stop") {
148
+ process.stdout.write(`${JSON.stringify(await stopService())}\n`);
149
+ return;
150
+ }
151
+ if (command === "restart") {
152
+ await stopService();
153
+ process.stdout.write(`${JSON.stringify(await startService())}\n`);
154
+ return;
155
+ }
156
+ if (command === "install") {
157
+ process.stdout.write(`${JSON.stringify(installStartup())}\n`);
158
+ return;
159
+ }
160
+ if (command === "uninstall") {
161
+ process.stdout.write(`${JSON.stringify(uninstallStartup())}\n`);
162
+ return;
163
+ }
164
+ if (command === "startup-status") {
165
+ const status = getStartupStatus();
166
+ process.stdout.write(`${JSON.stringify(status)}\n`);
167
+ process.exitCode = status.configured ? 0 : 1;
168
+ return;
169
+ }
170
+ printUsage();
171
+ process.exitCode = 2;
172
+ }
173
+
174
+ if (require.main === module) {
175
+ main().catch((error) => {
176
+ console.error(`gecho-bridge-server: ${error.message || error}`);
177
+ process.exitCode = 1;
178
+ });
179
+ }
180
+
181
+ module.exports = {
182
+ getServiceInfo,
183
+ isCompatibleService,
184
+ main,
185
+ startService,
186
+ stopService
187
+ };
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const fs = require("node:fs");
6
+ const os = require("node:os");
7
+ const path = require("node:path");
8
+ const { spawnSync } = require("node:child_process");
9
+
10
+ const DEFAULT_LABEL = "ai.gecho.bridge";
11
+ const DEFAULT_UNIT_NAME = "gecho-bridge.service";
12
+ const DEFAULT_TASK_NAME = "Gecho Bridge";
13
+
14
+ function xmlEscape(value) {
15
+ return String(value ?? "")
16
+ .replace(/&/g, "&amp;")
17
+ .replace(/</g, "&lt;")
18
+ .replace(/>/g, "&gt;")
19
+ .replace(/"/g, "&quot;")
20
+ .replace(/'/g, "&apos;");
21
+ }
22
+
23
+ function systemdQuote(value) {
24
+ return `"${String(value ?? "")
25
+ .replace(/\\/g, "\\\\")
26
+ .replace(/"/g, '\\"')
27
+ .replace(/\$/g, "\\$")}`;
28
+ }
29
+
30
+ function windowsQuote(value) {
31
+ return `"${String(value ?? "").replace(/"/g, '\\"')}"`;
32
+ }
33
+
34
+ function normalizedPort(value, fallback) {
35
+ const port = Number(value || fallback);
36
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : fallback;
37
+ }
38
+
39
+ function defaultDataDirForPlatform(platform, homeDir) {
40
+ if (platform === "darwin") {
41
+ return path.join(homeDir, "Library", "Application Support", "Gecho", "Bridge");
42
+ }
43
+ if (platform === "win32") {
44
+ return path.join(process.env.LOCALAPPDATA || path.join(homeDir, "AppData", "Local"), "Gecho", "Bridge");
45
+ }
46
+ return path.join(process.env.XDG_STATE_HOME || path.join(homeDir, ".local", "state"), "gecho", "bridge");
47
+ }
48
+
49
+ function resolveOptions(options = {}) {
50
+ const platform = options.platform || process.platform;
51
+ const homeDir = path.resolve(options.homeDir || os.homedir());
52
+ const dataDir = path.resolve(options.dataDir || process.env.GECHO_DATA_DIR || defaultDataDirForPlatform(platform, homeDir));
53
+ const servicePath = path.resolve(options.servicePath || path.join(__dirname, "server.js"));
54
+ return {
55
+ ...options,
56
+ platform,
57
+ homeDir,
58
+ nodePath: path.resolve(options.nodePath || process.execPath),
59
+ servicePath,
60
+ workingDirectory: path.resolve(options.workingDirectory || path.dirname(servicePath)),
61
+ dataDir,
62
+ httpPort: normalizedPort(options.httpPort || process.env.GECHO_HTTP_PORT, 18793),
63
+ wsPort: normalizedPort(options.wsPort || process.env.GECHO_WS_PORT, 18792),
64
+ websiteUrl: String(options.websiteUrl || process.env.GECHO_WEBSITE_URL || "https://gecho.ai/").trim(),
65
+ allowedOrigins: String(options.allowedOrigins ?? process.env.GECHO_SCHEDULED_ALLOWED_ORIGINS ?? "").trim(),
66
+ allowLocalhost: String(options.allowLocalhost ?? process.env.GECHO_SCHEDULED_ALLOW_LOCALHOST ?? "").trim(),
67
+ browser: String(options.browser || process.env.GECHO_BROWSER || "").trim(),
68
+ autoLaunchBrowser: String(options.autoLaunchBrowser ?? process.env.GECHO_AUTO_LAUNCH_BROWSER ?? "").trim(),
69
+ autoOpenOnboarding: String(options.autoOpenOnboarding ?? process.env.GECHO_AUTO_OPEN_ONBOARDING_ON_FIRST_START ?? "").trim(),
70
+ autoCloseBrowser: String(options.autoCloseBrowser ?? process.env.GECHO_AUTO_CLOSE_LAUNCHED_BROWSER ?? "").trim(),
71
+ label: String(options.label || DEFAULT_LABEL).trim(),
72
+ unitName: String(options.unitName || DEFAULT_UNIT_NAME).trim(),
73
+ taskName: String(options.taskName || DEFAULT_TASK_NAME).trim()
74
+ };
75
+ }
76
+
77
+ function buildStartupDescriptor(options = {}) {
78
+ const resolved = resolveOptions(options);
79
+ if (resolved.platform === "darwin") {
80
+ return {
81
+ kind: "launch_agent",
82
+ scope: "user",
83
+ label: resolved.label,
84
+ path: path.join(resolved.homeDir, "Library", "LaunchAgents", `${resolved.label}.plist`)
85
+ };
86
+ }
87
+ if (resolved.platform === "linux") {
88
+ return {
89
+ kind: "systemd_user",
90
+ scope: "user",
91
+ unitName: resolved.unitName,
92
+ path: path.join(resolved.homeDir, ".config", "systemd", "user", resolved.unitName)
93
+ };
94
+ }
95
+ if (resolved.platform === "win32") {
96
+ return {
97
+ kind: "windows_task",
98
+ scope: "user",
99
+ taskName: resolved.taskName
100
+ };
101
+ }
102
+ return { kind: "unsupported", scope: "user" };
103
+ }
104
+
105
+ function safeEnvironmentEntries(options = {}) {
106
+ const resolved = resolveOptions(options);
107
+ const entries = [
108
+ ["GECHO_DATA_DIR", resolved.dataDir],
109
+ ["GECHO_HTTP_PORT", String(resolved.httpPort)],
110
+ ["GECHO_WS_PORT", String(resolved.wsPort)],
111
+ ["GECHO_WEBSITE_URL", resolved.websiteUrl],
112
+ ["GECHO_SCHEDULED_ALLOWED_ORIGINS", resolved.allowedOrigins],
113
+ ["GECHO_SCHEDULED_ALLOW_LOCALHOST", resolved.allowLocalhost],
114
+ ["GECHO_BROWSER", resolved.browser],
115
+ ["GECHO_AUTO_LAUNCH_BROWSER", resolved.autoLaunchBrowser],
116
+ ["GECHO_AUTO_OPEN_ONBOARDING_ON_FIRST_START", resolved.autoOpenOnboarding],
117
+ ["GECHO_AUTO_CLOSE_LAUNCHED_BROWSER", resolved.autoCloseBrowser]
118
+ ];
119
+ return entries.filter(([, value]) => String(value || "").trim() !== "");
120
+ }
121
+
122
+ function createMacLaunchAgentPlist(options = {}) {
123
+ const resolved = resolveOptions({ ...options, platform: "darwin" });
124
+ const descriptor = buildStartupDescriptor(resolved);
125
+ if (descriptor.kind !== "launch_agent") {
126
+ throw new Error("macOS LaunchAgent configuration requires platform=darwin");
127
+ }
128
+ const programArguments = [resolved.nodePath, resolved.servicePath];
129
+ const environmentEntries = safeEnvironmentEntries(resolved);
130
+ const xmlArray = (values) => values.map((value) => ` <string>${xmlEscape(value)}</string>`).join("\n");
131
+ const xmlEnvironment = environmentEntries.length
132
+ ? `\n <key>EnvironmentVariables</key>\n <dict>\n${environmentEntries.map(([key, value]) => ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`).join("\n")}\n </dict>`
133
+ : "";
134
+ return `<?xml version="1.0" encoding="UTF-8"?>
135
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
136
+ <plist version="1.0">
137
+ <dict>
138
+ <key>Label</key>
139
+ <string>${xmlEscape(resolved.label)}</string>
140
+ <key>ProgramArguments</key>
141
+ <array>
142
+ ${xmlArray(programArguments)}
143
+ </array>
144
+ <key>WorkingDirectory</key>
145
+ <string>${xmlEscape(resolved.workingDirectory)}</string>
146
+ <key>RunAtLoad</key>
147
+ <true/>
148
+ <key>KeepAlive</key>
149
+ <true/>
150
+ <key>ProcessType</key>
151
+ <string>Background</string>
152
+ <key>ThrottleInterval</key>
153
+ <integer>3</integer>${xmlEnvironment}
154
+ <key>StandardOutPath</key>
155
+ <string>${xmlEscape(path.join(resolved.dataDir, "bridge-service.log"))}</string>
156
+ <key>StandardErrorPath</key>
157
+ <string>${xmlEscape(path.join(resolved.dataDir, "bridge-service.error.log"))}</string>
158
+ </dict>
159
+ </plist>
160
+ `;
161
+ }
162
+
163
+ function createSystemdUserUnit(options = {}) {
164
+ const resolved = resolveOptions({ ...options, platform: "linux" });
165
+ const descriptor = buildStartupDescriptor(resolved);
166
+ if (descriptor.kind !== "systemd_user") {
167
+ throw new Error("systemd user configuration requires platform=linux");
168
+ }
169
+ const environmentLines = safeEnvironmentEntries(resolved)
170
+ .map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`)
171
+ .join("\n");
172
+ return `[Unit]
173
+ Description=Gecho Bridge local service
174
+ After=network-online.target
175
+
176
+ [Service]
177
+ Type=simple
178
+ ExecStart=${systemdQuote(resolved.nodePath)} ${systemdQuote(resolved.servicePath)}
179
+ WorkingDirectory=${systemdQuote(resolved.workingDirectory)}
180
+ ${environmentLines}
181
+ Restart=on-failure
182
+ RestartSec=3
183
+ StandardOutput=${systemdQuote(`append:${path.join(resolved.dataDir, "bridge-service.log")}`)}
184
+ StandardError=${systemdQuote(`append:${path.join(resolved.dataDir, "bridge-service.error.log")}`)}
185
+
186
+ [Install]
187
+ WantedBy=default.target
188
+ `;
189
+ }
190
+
191
+ function createWindowsTaskArgs(options = {}) {
192
+ const resolved = resolveOptions({ ...options, platform: "win32" });
193
+ return [
194
+ "/Create",
195
+ "/TN",
196
+ resolved.taskName,
197
+ "/TR",
198
+ `${windowsQuote(resolved.nodePath)} ${windowsQuote(resolved.servicePath)}`,
199
+ "/SC",
200
+ "ONLOGON",
201
+ "/RL",
202
+ "LIMITED",
203
+ "/F"
204
+ ];
205
+ }
206
+
207
+ function writeStartupConfig(options = {}) {
208
+ const resolved = resolveOptions(options);
209
+ const descriptor = buildStartupDescriptor(resolved);
210
+ if (descriptor.kind === "unsupported") {
211
+ const error = new Error(`Unsupported platform: ${resolved.platform}`);
212
+ error.code = "STARTUP_PLATFORM_UNSUPPORTED";
213
+ throw error;
214
+ }
215
+ if (descriptor.kind === "windows_task") return descriptor;
216
+
217
+ fs.mkdirSync(path.dirname(descriptor.path), { recursive: true, mode: 0o700 });
218
+ fs.mkdirSync(resolved.dataDir, { recursive: true, mode: 0o700 });
219
+ const contents = descriptor.kind === "launch_agent"
220
+ ? createMacLaunchAgentPlist(resolved)
221
+ : createSystemdUserUnit(resolved);
222
+ fs.writeFileSync(descriptor.path, contents, { encoding: "utf8", mode: 0o600 });
223
+ fs.chmodSync(descriptor.path, 0o600);
224
+ return descriptor;
225
+ }
226
+
227
+ function runCommand(command, args, options = {}) {
228
+ const runner = options.runCommand || spawnSync;
229
+ const result = runner(command, args, {
230
+ encoding: "utf8",
231
+ stdio: ["ignore", "pipe", "pipe"]
232
+ });
233
+ if (result?.error) throw result.error;
234
+ if (result?.status !== 0) {
235
+ const detail = String(result?.stderr || result?.stdout || "").trim();
236
+ const error = new Error(`${command} failed${detail ? `: ${detail}` : ""}`);
237
+ error.code = "STARTUP_COMMAND_FAILED";
238
+ error.status = result?.status;
239
+ throw error;
240
+ }
241
+ return result;
242
+ }
243
+
244
+ function runCommandBestEffort(command, args, options = {}) {
245
+ try {
246
+ return { ok: true, result: runCommand(command, args, options) };
247
+ } catch (error) {
248
+ return { ok: false, error };
249
+ }
250
+ }
251
+
252
+ function currentGuiTarget() {
253
+ if (typeof process.getuid !== "function") {
254
+ const error = new Error("Current user id is unavailable");
255
+ error.code = "STARTUP_USER_ID_UNAVAILABLE";
256
+ throw error;
257
+ }
258
+ return `gui/${process.getuid()}`;
259
+ }
260
+
261
+ function installStartup(options = {}) {
262
+ const resolved = resolveOptions(options);
263
+ const descriptor = writeStartupConfig(resolved);
264
+ if (descriptor.kind === "launch_agent") {
265
+ const target = currentGuiTarget();
266
+ runCommandBestEffort("launchctl", ["bootout", target, descriptor.path], resolved);
267
+ runCommand("launchctl", ["bootstrap", target, descriptor.path], resolved);
268
+ return { status: "installed", descriptor, active: true };
269
+ }
270
+ if (descriptor.kind === "systemd_user") {
271
+ runCommand("systemctl", ["--user", "daemon-reload"], resolved);
272
+ runCommand("systemctl", ["--user", "enable", "--now", descriptor.unitName], resolved);
273
+ return { status: "installed", descriptor, active: true };
274
+ }
275
+ if (descriptor.kind === "windows_task") {
276
+ runCommand("schtasks.exe", createWindowsTaskArgs(resolved), resolved);
277
+ return { status: "installed", descriptor, active: true };
278
+ }
279
+ const error = new Error(`Unsupported platform: ${resolved.platform}`);
280
+ error.code = "STARTUP_PLATFORM_UNSUPPORTED";
281
+ throw error;
282
+ }
283
+
284
+ function uninstallStartup(options = {}) {
285
+ const resolved = resolveOptions(options);
286
+ const descriptor = buildStartupDescriptor(resolved);
287
+ if (descriptor.kind === "launch_agent") {
288
+ runCommandBestEffort("launchctl", ["bootout", currentGuiTarget(), descriptor.path], resolved);
289
+ fs.rmSync(descriptor.path, { force: true });
290
+ return { status: "uninstalled", descriptor, active: false };
291
+ }
292
+ if (descriptor.kind === "systemd_user") {
293
+ runCommandBestEffort("systemctl", ["--user", "disable", "--now", descriptor.unitName], resolved);
294
+ fs.rmSync(descriptor.path, { force: true });
295
+ runCommandBestEffort("systemctl", ["--user", "daemon-reload"], resolved);
296
+ return { status: "uninstalled", descriptor, active: false };
297
+ }
298
+ if (descriptor.kind === "windows_task") {
299
+ runCommandBestEffort("schtasks.exe", ["/Delete", "/TN", descriptor.taskName, "/F"], resolved);
300
+ return { status: "uninstalled", descriptor, active: false };
301
+ }
302
+ const error = new Error(`Unsupported platform: ${resolved.platform}`);
303
+ error.code = "STARTUP_PLATFORM_UNSUPPORTED";
304
+ throw error;
305
+ }
306
+
307
+ function getStartupStatus(options = {}) {
308
+ const resolved = resolveOptions(options);
309
+ const descriptor = buildStartupDescriptor(resolved);
310
+ if (descriptor.kind === "unsupported") return { status: "unsupported", descriptor, configured: false, active: false };
311
+
312
+ if (descriptor.kind === "launch_agent") {
313
+ const configured = fs.existsSync(descriptor.path);
314
+ const active = configured && runCommandBestEffort("launchctl", ["print", `${currentGuiTarget()}/${descriptor.label}`], resolved).ok;
315
+ return { status: configured ? "configured" : "not configured", descriptor, configured, active };
316
+ }
317
+ if (descriptor.kind === "systemd_user") {
318
+ const configured = fs.existsSync(descriptor.path);
319
+ const enabled = runCommandBestEffort("systemctl", ["--user", "is-enabled", descriptor.unitName], resolved).ok;
320
+ const active = runCommandBestEffort("systemctl", ["--user", "is-active", descriptor.unitName], resolved).ok;
321
+ return { status: configured ? "configured" : "not configured", descriptor, configured, enabled, active };
322
+ }
323
+ const queried = runCommandBestEffort("schtasks.exe", ["/Query", "/TN", descriptor.taskName], resolved);
324
+ return { status: queried.ok ? "configured" : "not configured", descriptor, configured: queried.ok, active: queried.ok };
325
+ }
326
+
327
+ module.exports = {
328
+ buildStartupDescriptor,
329
+ createMacLaunchAgentPlist,
330
+ createSystemdUserUnit,
331
+ createWindowsTaskArgs,
332
+ getStartupStatus,
333
+ installStartup,
334
+ uninstallStartup,
335
+ writeStartupConfig
336
+ };