@hienlh/ppm 0.7.28 → 0.7.29

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.29] - 2026-03-22
4
+
5
+ ### Added
6
+ - **Auto-start on boot**: `ppm autostart enable/disable/status` registers PPM to start automatically via OS-native mechanisms — macOS launchd, Linux systemd, Windows Registry Run key
7
+ - **Cross-platform support**: auto-detects compiled binary vs bun runtime, resolves paths correctly on all platforms
8
+ - **VBScript hidden wrapper**: Windows auto-start runs PPM without visible console window
9
+ - **GitHub Actions CI**: test matrix for macOS, Linux, and Windows with unit + integration tests
10
+
11
+ ### Technical
12
+ - 2-layer architecture: generator (pure functions, testable) + register (OS interaction)
13
+ - 40 unit tests + 20 integration tests (platform-specific with `describe.if`)
14
+ - KeepAlive/Restart-on-failure for crash recovery on all platforms
15
+
3
16
  ## [0.7.28] - 2026-03-22
4
17
 
5
18
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.7.28",
3
+ "version": "0.7.29",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -0,0 +1,94 @@
1
+ import type { Command } from "commander";
2
+ import { configService } from "../../services/config.service.ts";
3
+
4
+ export function registerAutoStartCommands(program: Command): void {
5
+ const cmd = program
6
+ .command("autostart")
7
+ .description("Manage auto-start on boot (enable/disable/status)");
8
+
9
+ cmd
10
+ .command("enable")
11
+ .description("Register PPM to start automatically on boot")
12
+ .option("-p, --port <port>", "Override port")
13
+ .option("-s, --share", "Enable Cloudflare tunnel on boot")
14
+ .option("-c, --config <path>", "Config file path")
15
+ .option("--profile <name>", "DB profile name")
16
+ .action(async (options) => {
17
+ const { enableAutoStart } = await import("../../services/autostart-register.ts");
18
+
19
+ configService.load(options.config);
20
+ const port = parseInt(options.port ?? String(configService.get("port")), 10);
21
+ const host = configService.get("host") ?? "0.0.0.0";
22
+
23
+ const config = {
24
+ port,
25
+ host,
26
+ share: !!options.share,
27
+ configPath: options.config,
28
+ profile: options.profile,
29
+ };
30
+
31
+ try {
32
+ console.log(" Registering auto-start...\n");
33
+ const servicePath = await enableAutoStart(config);
34
+ console.log(` ✓ Auto-start enabled`);
35
+ console.log(` Service: ${servicePath}`);
36
+ console.log(` Port: ${port}`);
37
+ if (options.share) console.log(` Tunnel: enabled`);
38
+ console.log(`\n PPM will start automatically on boot.`);
39
+
40
+ if (process.platform === "darwin") {
41
+ console.log(`\n Note: On macOS Ventura+, you may need to allow PPM in`);
42
+ console.log(` System Settings > General > Login Items.`);
43
+ }
44
+ if (process.platform === "linux") {
45
+ console.log(`\n Note: 'loginctl enable-linger' was called to allow`);
46
+ console.log(` boot-time start without login.`);
47
+ }
48
+ console.log();
49
+ } catch (err: unknown) {
50
+ const msg = err instanceof Error ? err.message : String(err);
51
+ console.error(` ✗ Failed to enable auto-start: ${msg}\n`);
52
+ process.exit(1);
53
+ }
54
+ });
55
+
56
+ cmd
57
+ .command("disable")
58
+ .description("Remove PPM auto-start registration")
59
+ .action(async () => {
60
+ const { disableAutoStart } = await import("../../services/autostart-register.ts");
61
+
62
+ try {
63
+ await disableAutoStart();
64
+ console.log(" ✓ Auto-start disabled. PPM will no longer start on boot.\n");
65
+ } catch (err: unknown) {
66
+ const msg = err instanceof Error ? err.message : String(err);
67
+ console.error(` ✗ Failed to disable auto-start: ${msg}\n`);
68
+ process.exit(1);
69
+ }
70
+ });
71
+
72
+ cmd
73
+ .command("status")
74
+ .description("Show auto-start status")
75
+ .option("--json", "Output as JSON")
76
+ .action(async (options) => {
77
+ const { getAutoStartStatus } = await import("../../services/autostart-register.ts");
78
+
79
+ const status = getAutoStartStatus();
80
+
81
+ if (options.json) {
82
+ console.log(JSON.stringify(status));
83
+ return;
84
+ }
85
+
86
+ console.log(`\n Auto-start status\n`);
87
+ console.log(` Platform: ${status.platform}`);
88
+ console.log(` Enabled: ${status.enabled ? "yes" : "no"}`);
89
+ console.log(` Running: ${status.running ? "yes" : "no"}`);
90
+ if (status.servicePath) console.log(` Service: ${status.servicePath}`);
91
+ console.log(` Details: ${status.details}`);
92
+ console.log();
93
+ });
94
+ }
package/src/index.ts CHANGED
@@ -121,4 +121,7 @@ registerGitCommands(program);
121
121
  const { registerChatCommands } = await import("./cli/commands/chat-cmd.ts");
122
122
  registerChatCommands(program);
123
123
 
124
+ const { registerAutoStartCommands } = await import("./cli/commands/autostart.ts");
125
+ registerAutoStartCommands(program);
126
+
124
127
  program.parse();
@@ -0,0 +1,213 @@
1
+ import { homedir } from "node:os";
2
+ import { resolve } from "node:path";
3
+
4
+ export interface AutoStartConfig {
5
+ port: number;
6
+ host: string;
7
+ share: boolean;
8
+ configPath?: string;
9
+ profile?: string;
10
+ }
11
+
12
+ /** Detect whether running from compiled binary or bun runtime */
13
+ export function isCompiledBinary(): boolean {
14
+ // Compiled Bun binaries don't have "bun" in execPath
15
+ return !process.execPath.includes("bun");
16
+ }
17
+
18
+ /** Resolve the absolute path to the bun binary */
19
+ export function resolveBunPath(): string {
20
+ // 1. Current process is bun itself
21
+ if (process.execPath.includes("bun")) return process.execPath;
22
+
23
+ // 2. Check ~/.bun/bin/bun
24
+ const home = homedir();
25
+ const bunHome = resolve(home, ".bun", "bin", "bun");
26
+ try {
27
+ const { existsSync } = require("node:fs") as typeof import("node:fs");
28
+ if (existsSync(bunHome)) return bunHome;
29
+ } catch {}
30
+
31
+ // 3. Check PATH via which/where
32
+ try {
33
+ const cmd = process.platform === "win32" ? ["where", "bun"] : ["which", "bun"];
34
+ const result = Bun.spawnSync({ cmd, stdout: "pipe", stderr: "ignore" });
35
+ const path = result.stdout.toString().trim().split("\n")[0];
36
+ if (path) return path;
37
+ } catch {}
38
+
39
+ throw new Error("Could not resolve bun binary. Install Bun or add it to PATH.");
40
+ }
41
+
42
+ /** Build the command array for the PPM server process */
43
+ export function buildExecCommand(config: AutoStartConfig): string[] {
44
+ if (isCompiledBinary()) {
45
+ // Compiled binary: just run self with __serve__ args
46
+ const args = [process.execPath, "__serve__", String(config.port), config.host];
47
+ if (config.configPath) args.push(config.configPath);
48
+ if (config.profile) args.push(config.profile);
49
+ return args;
50
+ }
51
+
52
+ // Bun runtime: bun run <script> __serve__ <port> <host> [config] [profile]
53
+ const bunPath = resolveBunPath();
54
+ const scriptPath = resolve(import.meta.dir, "..", "server", "index.ts");
55
+ const args = [bunPath, "run", scriptPath, "__serve__", String(config.port), config.host];
56
+ if (config.configPath) args.push(config.configPath);
57
+ else args.push(""); // placeholder
58
+ if (config.profile) args.push(config.profile);
59
+ else args.push(""); // placeholder
60
+ return args;
61
+ }
62
+
63
+ // ─── macOS launchd plist ────────────────────────────────────────────────
64
+
65
+ const PLIST_LABEL = "com.hienlh.ppm";
66
+
67
+ export function getPlistPath(): string {
68
+ return resolve(homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
69
+ }
70
+
71
+ /** Generate macOS launchd plist XML content */
72
+ export function generatePlist(config: AutoStartConfig): string {
73
+ const cmd = buildExecCommand(config);
74
+ const logPath = resolve(homedir(), ".ppm", "ppm-launchd.log");
75
+
76
+ const programArgs = cmd
77
+ .map((arg) => ` <string>${escapeXml(arg)}</string>`)
78
+ .join("\n");
79
+
80
+ return `<?xml version="1.0" encoding="UTF-8"?>
81
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
82
+ <plist version="1.0">
83
+ <dict>
84
+ <key>Label</key>
85
+ <string>${PLIST_LABEL}</string>
86
+ <key>ProgramArguments</key>
87
+ <array>
88
+ ${programArgs}
89
+ </array>
90
+ <key>RunAtLoad</key>
91
+ <true/>
92
+ <key>KeepAlive</key>
93
+ <dict>
94
+ <key>SuccessfulExit</key>
95
+ <false/>
96
+ </dict>
97
+ <key>StandardOutPath</key>
98
+ <string>${escapeXml(logPath)}</string>
99
+ <key>StandardErrorPath</key>
100
+ <string>${escapeXml(logPath)}</string>
101
+ <key>WorkingDirectory</key>
102
+ <string>${escapeXml(resolve(homedir(), ".ppm"))}</string>
103
+ <key>ThrottleInterval</key>
104
+ <integer>10</integer>
105
+ </dict>
106
+ </plist>
107
+ `;
108
+ }
109
+
110
+ // ─── Linux systemd service ──────────────────────────────────────────────
111
+
112
+ export function getServicePath(): string {
113
+ return resolve(homedir(), ".config", "systemd", "user", "ppm.service");
114
+ }
115
+
116
+ /** Generate Linux systemd user service file content */
117
+ export function generateSystemdService(config: AutoStartConfig): string {
118
+ const cmd = buildExecCommand(config);
119
+ const execStart = cmd.map(shellEscape).join(" ");
120
+ const bunDir = isCompiledBinary() ? "" : resolve(resolveBunPath(), "..");
121
+
122
+ // Build PATH with bun directory prepended
123
+ const envPath = bunDir
124
+ ? `Environment="PATH=${bunDir}:/usr/local/bin:/usr/bin:/bin"`
125
+ : "";
126
+
127
+ return `[Unit]
128
+ Description=PPM - Personal Project Manager
129
+ Documentation=https://github.com/hienlh/ppm
130
+ After=network-online.target
131
+ Wants=network-online.target
132
+
133
+ [Service]
134
+ Type=simple
135
+ ExecStart=${execStart}
136
+ Restart=on-failure
137
+ RestartSec=5
138
+ ${envPath}
139
+ WorkingDirectory=${homedir()}/.ppm
140
+
141
+ [Install]
142
+ WantedBy=default.target
143
+ `;
144
+ }
145
+
146
+ // ─── Windows Registry Run key ───────────────────────────────────────────
147
+ // Uses HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run — no admin needed
148
+
149
+ const TASK_NAME = "PPM";
150
+ const WIN_REG_KEY = "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run";
151
+
152
+ /** Generate Windows VBScript wrapper content to run PPM hidden */
153
+ export function generateVbsWrapper(config: AutoStartConfig): string {
154
+ const cmd = buildExecCommand(config);
155
+ const exe = cmd[0]!;
156
+ const args = cmd.slice(1).join(" ");
157
+ return `Set objShell = CreateObject("WScript.Shell")
158
+ objShell.Run """${exe.replace(/\\/g, "\\\\")}""` +
159
+ ` ${args.replace(/"/g, '""')}", 0, False
160
+ `;
161
+ }
162
+
163
+ export function getVbsPath(): string {
164
+ return resolve(homedir(), ".ppm", "run-ppm.vbs");
165
+ }
166
+
167
+ /** Build reg command to add PPM to Windows startup (no admin) */
168
+ export function buildRegAddCommand(vbsPath: string): string[] {
169
+ return [
170
+ "reg", "add", WIN_REG_KEY,
171
+ "/v", TASK_NAME,
172
+ "/t", "REG_SZ",
173
+ "/d", `cscript.exe "${vbsPath}"`,
174
+ "/f",
175
+ ];
176
+ }
177
+
178
+ /** Build reg command to remove PPM from Windows startup */
179
+ export function buildRegDeleteCommand(): string[] {
180
+ return [
181
+ "reg", "delete", WIN_REG_KEY,
182
+ "/v", TASK_NAME,
183
+ "/f",
184
+ ];
185
+ }
186
+
187
+ /** Build reg command to query PPM startup entry */
188
+ export function buildRegQueryCommand(): string[] {
189
+ return [
190
+ "reg", "query", WIN_REG_KEY,
191
+ "/v", TASK_NAME,
192
+ ];
193
+ }
194
+
195
+ // ─── Helpers ────────────────────────────────────────────────────────────
196
+
197
+ /** Escape special XML characters */
198
+ function escapeXml(s: string): string {
199
+ return s
200
+ .replace(/&/g, "&amp;")
201
+ .replace(/</g, "&lt;")
202
+ .replace(/>/g, "&gt;")
203
+ .replace(/"/g, "&quot;")
204
+ .replace(/'/g, "&apos;");
205
+ }
206
+
207
+ /** Escape a string for shell usage (wrap in quotes if contains spaces) */
208
+ function shellEscape(s: string): string {
209
+ if (/["\s]/.test(s)) return `"${s.replace(/"/g, '\\"')}"`;
210
+ return s;
211
+ }
212
+
213
+ export { PLIST_LABEL, TASK_NAME };
@@ -0,0 +1,348 @@
1
+ import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import {
5
+ type AutoStartConfig,
6
+ PLIST_LABEL,
7
+ TASK_NAME,
8
+ generatePlist,
9
+ getPlistPath,
10
+ generateSystemdService,
11
+ getServicePath,
12
+ generateVbsWrapper,
13
+ getVbsPath,
14
+ buildRegAddCommand,
15
+ buildRegDeleteCommand,
16
+ buildRegQueryCommand,
17
+ } from "./autostart-generator.ts";
18
+
19
+ export interface AutoStartStatus {
20
+ enabled: boolean;
21
+ running: boolean;
22
+ platform: string;
23
+ servicePath: string | null;
24
+ details: string;
25
+ }
26
+
27
+ const METADATA_FILE = resolve(homedir(), ".ppm", "autostart.json");
28
+
29
+ interface AutoStartMetadata {
30
+ enabled: boolean;
31
+ platform: string;
32
+ servicePath: string;
33
+ createdAt: string;
34
+ config: AutoStartConfig;
35
+ }
36
+
37
+ /** Save autostart metadata to ~/.ppm/autostart.json */
38
+ function saveMetadata(meta: AutoStartMetadata): void {
39
+ const dir = dirname(METADATA_FILE);
40
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
41
+ writeFileSync(METADATA_FILE, JSON.stringify(meta, null, 2));
42
+ }
43
+
44
+ /** Load autostart metadata */
45
+ function loadMetadata(): AutoStartMetadata | null {
46
+ try {
47
+ if (!existsSync(METADATA_FILE)) return null;
48
+ return JSON.parse(readFileSync(METADATA_FILE, "utf-8"));
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /** Remove autostart metadata file */
55
+ function removeMetadata(): void {
56
+ try {
57
+ if (existsSync(METADATA_FILE)) unlinkSync(METADATA_FILE);
58
+ } catch {}
59
+ }
60
+
61
+ // ─── macOS ──────────────────────────────────────────────────────────────
62
+
63
+ async function enableMacOS(config: AutoStartConfig): Promise<string> {
64
+ const plistPath = getPlistPath();
65
+ const plistDir = dirname(plistPath);
66
+
67
+ if (!existsSync(plistDir)) mkdirSync(plistDir, { recursive: true });
68
+ writeFileSync(plistPath, generatePlist(config));
69
+
70
+ // Unload first if already loaded (ignore errors)
71
+ Bun.spawnSync({
72
+ cmd: ["launchctl", "bootout", `gui/${process.getuid!()}`, plistPath],
73
+ stdout: "ignore", stderr: "ignore",
74
+ });
75
+
76
+ // Load the agent
77
+ const result = Bun.spawnSync({
78
+ cmd: ["launchctl", "bootstrap", `gui/${process.getuid!()}`, plistPath],
79
+ stdout: "pipe", stderr: "pipe",
80
+ });
81
+
82
+ if (result.exitCode !== 0) {
83
+ // Fallback to legacy syntax
84
+ const legacy = Bun.spawnSync({
85
+ cmd: ["launchctl", "load", plistPath],
86
+ stdout: "pipe", stderr: "pipe",
87
+ });
88
+ if (legacy.exitCode !== 0) {
89
+ const err = legacy.stderr.toString().trim();
90
+ throw new Error(`launchctl load failed: ${err}`);
91
+ }
92
+ }
93
+
94
+ saveMetadata({
95
+ enabled: true,
96
+ platform: "darwin",
97
+ servicePath: plistPath,
98
+ createdAt: new Date().toISOString(),
99
+ config,
100
+ });
101
+
102
+ return plistPath;
103
+ }
104
+
105
+ async function disableMacOS(): Promise<void> {
106
+ const plistPath = getPlistPath();
107
+
108
+ // Unload
109
+ Bun.spawnSync({
110
+ cmd: ["launchctl", "bootout", `gui/${process.getuid!()}`, plistPath],
111
+ stdout: "ignore", stderr: "ignore",
112
+ });
113
+ // Legacy fallback
114
+ Bun.spawnSync({
115
+ cmd: ["launchctl", "unload", plistPath],
116
+ stdout: "ignore", stderr: "ignore",
117
+ });
118
+
119
+ // Remove plist file
120
+ try { if (existsSync(plistPath)) unlinkSync(plistPath); } catch {}
121
+ removeMetadata();
122
+ }
123
+
124
+ function statusMacOS(): AutoStartStatus {
125
+ const plistPath = getPlistPath();
126
+ const fileExists = existsSync(plistPath);
127
+
128
+ // Check if loaded
129
+ const result = Bun.spawnSync({
130
+ cmd: ["launchctl", "list"],
131
+ stdout: "pipe", stderr: "ignore",
132
+ });
133
+ const output = result.stdout.toString();
134
+ const isLoaded = output.includes(PLIST_LABEL);
135
+
136
+ return {
137
+ enabled: fileExists && isLoaded,
138
+ running: isLoaded,
139
+ platform: "darwin (launchd)",
140
+ servicePath: fileExists ? plistPath : null,
141
+ details: fileExists
142
+ ? isLoaded ? "Loaded and enabled" : "Plist exists but not loaded"
143
+ : "Not configured",
144
+ };
145
+ }
146
+
147
+ // ─── Linux ──────────────────────────────────────────────────────────────
148
+
149
+ async function enableLinux(config: AutoStartConfig): Promise<string> {
150
+ const servicePath = getServicePath();
151
+ const serviceDir = dirname(servicePath);
152
+
153
+ if (!existsSync(serviceDir)) mkdirSync(serviceDir, { recursive: true });
154
+ writeFileSync(servicePath, generateSystemdService(config));
155
+
156
+ // Reload daemon
157
+ const reload = Bun.spawnSync({
158
+ cmd: ["systemctl", "--user", "daemon-reload"],
159
+ stdout: "ignore", stderr: "pipe",
160
+ });
161
+ if (reload.exitCode !== 0) {
162
+ throw new Error(`systemctl daemon-reload failed: ${reload.stderr.toString().trim()}`);
163
+ }
164
+
165
+ // Enable
166
+ const enable = Bun.spawnSync({
167
+ cmd: ["systemctl", "--user", "enable", "ppm.service"],
168
+ stdout: "pipe", stderr: "pipe",
169
+ });
170
+ if (enable.exitCode !== 0) {
171
+ throw new Error(`systemctl enable failed: ${enable.stderr.toString().trim()}`);
172
+ }
173
+
174
+ // Start
175
+ Bun.spawnSync({
176
+ cmd: ["systemctl", "--user", "start", "ppm.service"],
177
+ stdout: "ignore", stderr: "ignore",
178
+ });
179
+
180
+ // Enable lingering so service runs at boot without login
181
+ Bun.spawnSync({
182
+ cmd: ["loginctl", "enable-linger", process.env.USER || ""],
183
+ stdout: "ignore", stderr: "ignore",
184
+ });
185
+
186
+ saveMetadata({
187
+ enabled: true,
188
+ platform: "linux",
189
+ servicePath,
190
+ createdAt: new Date().toISOString(),
191
+ config,
192
+ });
193
+
194
+ return servicePath;
195
+ }
196
+
197
+ async function disableLinux(): Promise<void> {
198
+ // Stop + disable
199
+ Bun.spawnSync({
200
+ cmd: ["systemctl", "--user", "stop", "ppm.service"],
201
+ stdout: "ignore", stderr: "ignore",
202
+ });
203
+ Bun.spawnSync({
204
+ cmd: ["systemctl", "--user", "disable", "ppm.service"],
205
+ stdout: "ignore", stderr: "ignore",
206
+ });
207
+
208
+ // Remove service file
209
+ const servicePath = getServicePath();
210
+ try { if (existsSync(servicePath)) unlinkSync(servicePath); } catch {}
211
+
212
+ // Reload
213
+ Bun.spawnSync({
214
+ cmd: ["systemctl", "--user", "daemon-reload"],
215
+ stdout: "ignore", stderr: "ignore",
216
+ });
217
+
218
+ removeMetadata();
219
+ }
220
+
221
+ function statusLinux(): AutoStartStatus {
222
+ const servicePath = getServicePath();
223
+ const fileExists = existsSync(servicePath);
224
+
225
+ // Check enabled
226
+ const enabled = Bun.spawnSync({
227
+ cmd: ["systemctl", "--user", "is-enabled", "ppm.service"],
228
+ stdout: "pipe", stderr: "ignore",
229
+ });
230
+ const isEnabled = enabled.stdout.toString().trim() === "enabled";
231
+
232
+ // Check active
233
+ const active = Bun.spawnSync({
234
+ cmd: ["systemctl", "--user", "is-active", "ppm.service"],
235
+ stdout: "pipe", stderr: "ignore",
236
+ });
237
+ const isActive = active.stdout.toString().trim() === "active";
238
+
239
+ return {
240
+ enabled: isEnabled,
241
+ running: isActive,
242
+ platform: "linux (systemd)",
243
+ servicePath: fileExists ? servicePath : null,
244
+ details: !fileExists
245
+ ? "Not configured"
246
+ : isEnabled && isActive ? "Enabled and running"
247
+ : isEnabled ? "Enabled but not running"
248
+ : "Service file exists but not enabled",
249
+ };
250
+ }
251
+
252
+ // ─── Windows ────────────────────────────────────────────────────────────
253
+
254
+ async function enableWindows(config: AutoStartConfig): Promise<string> {
255
+ const vbsPath = getVbsPath();
256
+ const vbsDir = dirname(vbsPath);
257
+
258
+ if (!existsSync(vbsDir)) mkdirSync(vbsDir, { recursive: true });
259
+ writeFileSync(vbsPath, generateVbsWrapper(config));
260
+
261
+ // Add to HKCU Run key (no admin required)
262
+ const cmd = buildRegAddCommand(vbsPath);
263
+ const result = Bun.spawnSync({ cmd, stdout: "pipe", stderr: "pipe" });
264
+
265
+ if (result.exitCode !== 0) {
266
+ const err = result.stderr.toString().trim();
267
+ throw new Error(`Registry add failed: ${err}`);
268
+ }
269
+
270
+ saveMetadata({
271
+ enabled: true,
272
+ platform: "win32",
273
+ servicePath: vbsPath,
274
+ createdAt: new Date().toISOString(),
275
+ config,
276
+ });
277
+
278
+ return vbsPath;
279
+ }
280
+
281
+ async function disableWindows(): Promise<void> {
282
+ // Remove from registry
283
+ const cmd = buildRegDeleteCommand();
284
+ Bun.spawnSync({ cmd, stdout: "ignore", stderr: "ignore" });
285
+
286
+ // Remove VBS wrapper
287
+ const vbsPath = getVbsPath();
288
+ try { if (existsSync(vbsPath)) unlinkSync(vbsPath); } catch {}
289
+
290
+ removeMetadata();
291
+ }
292
+
293
+ function statusWindows(): AutoStartStatus {
294
+ const vbsPath = getVbsPath();
295
+ const fileExists = existsSync(vbsPath);
296
+
297
+ // Check if registry entry exists
298
+ const cmd = buildRegQueryCommand();
299
+ const result = Bun.spawnSync({ cmd, stdout: "pipe", stderr: "ignore" });
300
+ const regExists = result.exitCode === 0 && result.stdout.toString().includes(TASK_NAME);
301
+
302
+ return {
303
+ enabled: regExists,
304
+ running: false, // Can't detect running state from registry
305
+ platform: "windows (Registry Run)",
306
+ servicePath: fileExists ? vbsPath : null,
307
+ details: regExists
308
+ ? "Registered (will run at next logon)"
309
+ : "Not configured",
310
+ };
311
+ }
312
+
313
+ // ─── Public API ─────────────────────────────────────────────────────────
314
+
315
+ /** Enable auto-start for the current platform */
316
+ export async function enableAutoStart(config: AutoStartConfig): Promise<string> {
317
+ const platform = process.platform;
318
+ if (platform === "darwin") return enableMacOS(config);
319
+ if (platform === "linux") return enableLinux(config);
320
+ if (platform === "win32") return enableWindows(config);
321
+ throw new Error(`Auto-start not supported on ${platform}`);
322
+ }
323
+
324
+ /** Disable auto-start for the current platform */
325
+ export async function disableAutoStart(): Promise<void> {
326
+ const platform = process.platform;
327
+ if (platform === "darwin") return disableMacOS();
328
+ if (platform === "linux") return disableLinux();
329
+ if (platform === "win32") return disableWindows();
330
+ throw new Error(`Auto-start not supported on ${platform}`);
331
+ }
332
+
333
+ /** Get auto-start status for the current platform */
334
+ export function getAutoStartStatus(): AutoStartStatus {
335
+ const platform = process.platform;
336
+ if (platform === "darwin") return statusMacOS();
337
+ if (platform === "linux") return statusLinux();
338
+ if (platform === "win32") return statusWindows();
339
+ return {
340
+ enabled: false,
341
+ running: false,
342
+ platform: `${platform} (unsupported)`,
343
+ servicePath: null,
344
+ details: `Auto-start not supported on ${platform}`,
345
+ };
346
+ }
347
+
348
+ export { loadMetadata, METADATA_FILE };