vexp-cli 2.0.11 → 2.0.13

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.
@@ -0,0 +1,375 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { execFileSync, spawnSync } from "child_process";
5
+ const LABEL = "io.vexp.serve";
6
+ const BEGIN_MARKER = "# vexp-autostart-begin";
7
+ const END_MARKER = "# vexp-autostart-end";
8
+ const INSTALL_MARKER_FILE = "autostart-installed";
9
+ function homedir() {
10
+ return os.homedir();
11
+ }
12
+ function installMarkerPath() {
13
+ return path.join(homedir(), ".vexp", INSTALL_MARKER_FILE);
14
+ }
15
+ /** Resolve the launcher the OS should execute. Prefers the `vexp` bin in
16
+ * PATH; falls back to node+dist/cli.cjs if invoked from a dev checkout. */
17
+ function resolveLauncher() {
18
+ if (process.env.VEXP_AUTOSTART_LAUNCHER) {
19
+ const parts = process.env.VEXP_AUTOSTART_LAUNCHER.split(" ");
20
+ return { bin: parts[0], args: [...parts.slice(1), "serve"] };
21
+ }
22
+ // `vexp` launches the commander CLI with argv-dispatch; argv[2] = "serve".
23
+ const hint = process.execPath && process.argv[1]
24
+ ? { bin: process.execPath, args: [process.argv[1], "serve"] }
25
+ : { bin: "vexp", args: ["serve"] };
26
+ // On Windows, prefer the global shim when available so the user's PATH
27
+ // can be used without baking in an absolute node path.
28
+ if (process.platform === "win32")
29
+ return { bin: "vexp.cmd", args: ["serve"] };
30
+ // Everywhere else default to node + resolved script path for reliability.
31
+ return hint;
32
+ }
33
+ // ───── macOS LaunchAgent ────────────────────────────────────────────────
34
+ function macPlistPath() {
35
+ return path.join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
36
+ }
37
+ function macPlist(launcher, logPath) {
38
+ const args = [launcher.bin, ...launcher.args]
39
+ .map((a) => ` <string>${a.replace(/&/g, "&amp;").replace(/</g, "&lt;")}</string>`)
40
+ .join("\n");
41
+ return `<?xml version="1.0" encoding="UTF-8"?>
42
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
43
+ <plist version="1.0">
44
+ <dict>
45
+ <key>Label</key><string>${LABEL}</string>
46
+ <key>ProgramArguments</key>
47
+ <array>
48
+ ${args}
49
+ </array>
50
+ <key>RunAtLoad</key><true/>
51
+ <key>KeepAlive</key><true/>
52
+ <key>StandardOutPath</key><string>${logPath}</string>
53
+ <key>StandardErrorPath</key><string>${logPath}</string>
54
+ <key>EnvironmentVariables</key>
55
+ <dict>
56
+ <key>PATH</key><string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
57
+ </dict>
58
+ </dict>
59
+ </plist>
60
+ `;
61
+ }
62
+ function installLaunchd() {
63
+ const launcher = resolveLauncher();
64
+ const logPath = path.join(homedir(), ".vexp", "autostart.log");
65
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
66
+ const plistPath = macPlistPath();
67
+ fs.mkdirSync(path.dirname(plistPath), { recursive: true });
68
+ fs.writeFileSync(plistPath, macPlist(launcher, logPath));
69
+ // Reload if already loaded.
70
+ try {
71
+ execFileSync("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}/${LABEL}`], { stdio: "ignore" });
72
+ }
73
+ catch { /* not loaded */ }
74
+ try {
75
+ execFileSync("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 501}`, plistPath], { stdio: "ignore" });
76
+ }
77
+ catch {
78
+ // Fallback for older macOS
79
+ try {
80
+ execFileSync("launchctl", ["load", "-w", plistPath], { stdio: "ignore" });
81
+ }
82
+ catch { /* best effort */ }
83
+ }
84
+ return { installed: true, mechanism: "launchd", path: plistPath };
85
+ }
86
+ function uninstallLaunchd() {
87
+ const plistPath = macPlistPath();
88
+ try {
89
+ execFileSync("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}/${LABEL}`], { stdio: "ignore" });
90
+ }
91
+ catch { /* not loaded */ }
92
+ try {
93
+ execFileSync("launchctl", ["unload", plistPath], { stdio: "ignore" });
94
+ }
95
+ catch { /* ok */ }
96
+ if (fs.existsSync(plistPath))
97
+ fs.unlinkSync(plistPath);
98
+ return true;
99
+ }
100
+ // ───── Linux: systemd --user > XDG autostart > shell profile ─────────────
101
+ function systemdUnitPath() {
102
+ return path.join(homedir(), ".config", "systemd", "user", "vexp.service");
103
+ }
104
+ function hasSystemdUser() {
105
+ const r = spawnSync("systemctl", ["--user", "is-system-running"], { encoding: "utf-8" });
106
+ // `is-system-running` returns various states; exit 0, 1, or 3 all mean the
107
+ // user bus is reachable. "offline" / "Failed to connect" → fallback.
108
+ if (r.error)
109
+ return false;
110
+ if ((r.stdout || "").includes("Failed to connect"))
111
+ return false;
112
+ if ((r.stderr || "").includes("Failed to connect"))
113
+ return false;
114
+ return r.status !== null && (r.status === 0 || r.status === 1 || r.status === 3);
115
+ }
116
+ function installSystemdUser() {
117
+ const launcher = resolveLauncher();
118
+ const unit = systemdUnitPath();
119
+ fs.mkdirSync(path.dirname(unit), { recursive: true });
120
+ const cmd = [launcher.bin, ...launcher.args].map((a) => /\s/.test(a) ? `"${a}"` : a).join(" ");
121
+ fs.writeFileSync(unit, `[Unit]
122
+ Description=vexp login supervisor
123
+ After=network.target
124
+
125
+ [Service]
126
+ Type=simple
127
+ ExecStart=${cmd}
128
+ Restart=on-failure
129
+ RestartSec=5
130
+ StandardOutput=append:${path.join(homedir(), ".vexp", "autostart.log")}
131
+ StandardError=append:${path.join(homedir(), ".vexp", "autostart.log")}
132
+
133
+ [Install]
134
+ WantedBy=default.target
135
+ `);
136
+ try {
137
+ execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
138
+ }
139
+ catch { /* best effort */ }
140
+ try {
141
+ execFileSync("systemctl", ["--user", "enable", "--now", "vexp.service"], { stdio: "ignore" });
142
+ }
143
+ catch { /* best effort */ }
144
+ return { installed: true, mechanism: "systemd-user", path: unit };
145
+ }
146
+ function uninstallSystemdUser() {
147
+ try {
148
+ execFileSync("systemctl", ["--user", "disable", "--now", "vexp.service"], { stdio: "ignore" });
149
+ }
150
+ catch { /* ok */ }
151
+ const unit = systemdUnitPath();
152
+ if (fs.existsSync(unit))
153
+ fs.unlinkSync(unit);
154
+ try {
155
+ execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
156
+ }
157
+ catch { /* ok */ }
158
+ return true;
159
+ }
160
+ function xdgAutostartPath() {
161
+ return path.join(homedir(), ".config", "autostart", "vexp.desktop");
162
+ }
163
+ function hasXdgDesktop() {
164
+ // If $XDG_CURRENT_DESKTOP or $DESKTOP_SESSION is set, a graphical session
165
+ // manager will honor ~/.config/autostart.
166
+ return !!(process.env.XDG_CURRENT_DESKTOP || process.env.DESKTOP_SESSION);
167
+ }
168
+ function installXdgAutostart() {
169
+ const launcher = resolveLauncher();
170
+ const file = xdgAutostartPath();
171
+ fs.mkdirSync(path.dirname(file), { recursive: true });
172
+ const cmd = [launcher.bin, ...launcher.args].map((a) => /\s/.test(a) ? `"${a}"` : a).join(" ");
173
+ fs.writeFileSync(file, `[Desktop Entry]
174
+ Type=Application
175
+ Name=vexp
176
+ Comment=vexp login supervisor
177
+ Exec=${cmd}
178
+ X-GNOME-Autostart-enabled=true
179
+ Terminal=false
180
+ Hidden=false
181
+ `);
182
+ return { installed: true, mechanism: "xdg-autostart", path: file };
183
+ }
184
+ function uninstallXdgAutostart() {
185
+ const file = xdgAutostartPath();
186
+ if (fs.existsSync(file))
187
+ fs.unlinkSync(file);
188
+ return true;
189
+ }
190
+ function shellProfilePaths() {
191
+ return [".bashrc", ".zshrc", ".profile"].map((f) => path.join(homedir(), f));
192
+ }
193
+ function installShellProfile() {
194
+ const launcher = resolveLauncher();
195
+ const cmd = [launcher.bin, ...launcher.args].map((a) => /\s/.test(a) ? `"${a}"` : a).join(" ");
196
+ const block = `${BEGIN_MARKER}
197
+ pgrep -f "vexp serve" >/dev/null 2>&1 || (nohup ${cmd} >> "${path.join(homedir(), ".vexp", "autostart.log")}" 2>&1 &)
198
+ ${END_MARKER}
199
+ `;
200
+ let wrote = "";
201
+ for (const p of shellProfilePaths()) {
202
+ let content = "";
203
+ try {
204
+ content = fs.readFileSync(p, "utf-8");
205
+ }
206
+ catch { /* new file */ }
207
+ if (content.includes(BEGIN_MARKER)) {
208
+ // Replace existing block
209
+ content = content.replace(new RegExp(`${BEGIN_MARKER}[\\s\\S]*?${END_MARKER}\\n?`), block);
210
+ }
211
+ else {
212
+ content = content + (content.endsWith("\n") || content === "" ? "" : "\n") + block;
213
+ }
214
+ try {
215
+ fs.writeFileSync(p, content);
216
+ wrote = p;
217
+ }
218
+ catch { /* permission denied, try next */ }
219
+ }
220
+ return { installed: !!wrote, mechanism: "shell-profile", path: wrote || undefined };
221
+ }
222
+ function uninstallShellProfile() {
223
+ let changed = false;
224
+ for (const p of shellProfilePaths()) {
225
+ let content = "";
226
+ try {
227
+ content = fs.readFileSync(p, "utf-8");
228
+ }
229
+ catch {
230
+ continue;
231
+ }
232
+ if (!content.includes(BEGIN_MARKER))
233
+ continue;
234
+ const cleaned = content.replace(new RegExp(`${BEGIN_MARKER}[\\s\\S]*?${END_MARKER}\\n?`), "");
235
+ try {
236
+ fs.writeFileSync(p, cleaned);
237
+ changed = true;
238
+ }
239
+ catch { /* ok */ }
240
+ }
241
+ return changed;
242
+ }
243
+ // ───── Windows: Startup folder VBScript ──────────────────────────────────
244
+ function windowsStartupPath() {
245
+ const appData = process.env.APPDATA || path.join(homedir(), "AppData", "Roaming");
246
+ return path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "vexp-serve.vbs");
247
+ }
248
+ function installWindowsStartup() {
249
+ const launcher = resolveLauncher();
250
+ const p = windowsStartupPath();
251
+ fs.mkdirSync(path.dirname(p), { recursive: true });
252
+ const logPath = path.join(homedir(), ".vexp", "autostart.log");
253
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
254
+ const cmdParts = [launcher.bin, ...launcher.args]
255
+ .map((a) => `""${a.replace(/"/g, '\\"')}""`)
256
+ .join(" ");
257
+ const script = `' vexp autostart — launches \`vexp serve\` hidden at login.\n` +
258
+ `Set sh = CreateObject("WScript.Shell")\n` +
259
+ `sh.Run "cmd /c " & ${JSON.stringify(cmdParts)} & " >> ""${logPath.replace(/\\/g, "\\\\")}"" 2>&1", 0, False\n`;
260
+ fs.writeFileSync(p, script);
261
+ return { installed: true, mechanism: "windows-startup", path: p };
262
+ }
263
+ function uninstallWindowsStartup() {
264
+ const p = windowsStartupPath();
265
+ if (fs.existsSync(p))
266
+ fs.unlinkSync(p);
267
+ return true;
268
+ }
269
+ // ───── Public API ────────────────────────────────────────────────────────
270
+ /**
271
+ * Install per-user autostart so `vexp serve` runs at login without admin.
272
+ * Chooses the best available mechanism per OS; returns what was written.
273
+ */
274
+ export function installAutostart() {
275
+ if (process.platform === "darwin")
276
+ return installLaunchd();
277
+ if (process.platform === "win32")
278
+ return installWindowsStartup();
279
+ if (process.platform === "linux") {
280
+ if (hasSystemdUser())
281
+ return installSystemdUser();
282
+ if (hasXdgDesktop())
283
+ return installXdgAutostart();
284
+ return installShellProfile();
285
+ }
286
+ return { installed: false, mechanism: "none" };
287
+ }
288
+ /** Remove every autostart variant, including legacy ones from prior versions. */
289
+ export function uninstallAutostart() {
290
+ if (process.platform === "darwin") {
291
+ uninstallLaunchd();
292
+ return;
293
+ }
294
+ if (process.platform === "win32") {
295
+ uninstallWindowsStartup();
296
+ return;
297
+ }
298
+ if (process.platform === "linux") {
299
+ uninstallSystemdUser();
300
+ uninstallXdgAutostart();
301
+ uninstallShellProfile();
302
+ }
303
+ try {
304
+ fs.unlinkSync(installMarkerPath());
305
+ }
306
+ catch { /* absent */ }
307
+ }
308
+ /**
309
+ * Idempotent install guard used by `vexp` preAction / REPL / plugin activate.
310
+ *
311
+ * First invocation after an upgrade installs autostart + writes a marker.
312
+ * Subsequent invocations (including `vexp setup`, any subcommand, multiple
313
+ * VS Code windows) short-circuit on the marker → zero extra work, zero
314
+ * process duplication.
315
+ *
316
+ * Respects `VEXP_NO_AUTOSTART_INSTALL=1` as opt-out.
317
+ */
318
+ export function installAutostartIfNeeded() {
319
+ if (process.env.VEXP_NO_AUTOSTART_INSTALL === "1")
320
+ return;
321
+ const marker = installMarkerPath();
322
+ if (fs.existsSync(marker))
323
+ return;
324
+ // Previous install by a different path (e.g. user ran older `vexp setup`
325
+ // that called installAutostart directly). Record the marker so we don't
326
+ // probe the filesystem again on every invocation.
327
+ if (autostartStatus().installed) {
328
+ try {
329
+ fs.mkdirSync(path.dirname(marker), { recursive: true });
330
+ fs.writeFileSync(marker, new Date().toISOString());
331
+ }
332
+ catch { /* non-fatal */ }
333
+ return;
334
+ }
335
+ try {
336
+ const r = installAutostart();
337
+ if (r.installed) {
338
+ try {
339
+ fs.mkdirSync(path.dirname(marker), { recursive: true });
340
+ fs.writeFileSync(marker, new Date().toISOString());
341
+ }
342
+ catch { /* non-fatal */ }
343
+ }
344
+ }
345
+ catch { /* non-fatal — opt-in best effort */ }
346
+ }
347
+ export function autostartStatus() {
348
+ if (process.platform === "darwin") {
349
+ const p = macPlistPath();
350
+ if (fs.existsSync(p))
351
+ return { installed: true, mechanism: "launchd", path: p };
352
+ return { installed: false, mechanism: "none" };
353
+ }
354
+ if (process.platform === "win32") {
355
+ const p = windowsStartupPath();
356
+ if (fs.existsSync(p))
357
+ return { installed: true, mechanism: "windows-startup", path: p };
358
+ return { installed: false, mechanism: "none" };
359
+ }
360
+ if (process.platform === "linux") {
361
+ if (fs.existsSync(systemdUnitPath()))
362
+ return { installed: true, mechanism: "systemd-user", path: systemdUnitPath() };
363
+ if (fs.existsSync(xdgAutostartPath()))
364
+ return { installed: true, mechanism: "xdg-autostart", path: xdgAutostartPath() };
365
+ for (const p of shellProfilePaths()) {
366
+ try {
367
+ if (fs.readFileSync(p, "utf-8").includes(BEGIN_MARKER)) {
368
+ return { installed: true, mechanism: "shell-profile", path: p };
369
+ }
370
+ }
371
+ catch { /* missing */ }
372
+ }
373
+ }
374
+ return { installed: false, mechanism: "none" };
375
+ }
package/dist/binary.js CHANGED
@@ -88,6 +88,69 @@ function ensureDevSonames(releaseDir) {
88
88
  // Non-fatal: user will get a clearer error from the binary itself.
89
89
  }
90
90
  }
91
+ // Copy ggml backend plugins (cpu/metal/blas) and default.metallib from
92
+ // the build output into releaseDir, so `ggml_backend_load_all()` can
93
+ // dlopen them. CI packaging does this for production bundles; in dev
94
+ // the files live in `target/release/build/.../out/bin/` and need a
95
+ // flat-copy here. Metal additionally needs default.metallib as a
96
+ // runtime resource.
97
+ copyDevBackendPlugins(releaseDir, ext);
98
+ }
99
+ /** Flat-copy backend .dylib/.so and Metal shader resources next to the
100
+ * binary so the runtime dynamic loader finds them. No-op if the files
101
+ * are already present. */
102
+ function copyDevBackendPlugins(releaseDir, ext) {
103
+ const buildDir = path.join(releaseDir, "build");
104
+ if (!fs.existsSync(buildDir))
105
+ return;
106
+ const backendNames = ["libggml-cpu", "libggml-blas"];
107
+ if (process.platform === "darwin")
108
+ backendNames.push("libggml-metal");
109
+ const scanRoots = [];
110
+ try {
111
+ for (const entry of fs.readdirSync(buildDir)) {
112
+ if (!entry.startsWith("llama-cpp-sys-"))
113
+ continue;
114
+ for (const sub of ["bin", "lib", "lib64"]) {
115
+ const d = path.join(buildDir, entry, "out", sub);
116
+ if (fs.existsSync(d))
117
+ scanRoots.push(d);
118
+ }
119
+ }
120
+ }
121
+ catch {
122
+ return;
123
+ }
124
+ const tryCopy = (src, dest) => {
125
+ if (fs.existsSync(dest))
126
+ return;
127
+ try {
128
+ fs.copyFileSync(src, dest);
129
+ }
130
+ catch { /* non-fatal */ }
131
+ };
132
+ for (const root of scanRoots) {
133
+ let entries = [];
134
+ try {
135
+ entries = fs.readdirSync(root);
136
+ }
137
+ catch {
138
+ continue;
139
+ }
140
+ for (const f of entries) {
141
+ // Backend plugin: libggml-cpu.dylib / libggml-metal.dylib / etc.
142
+ for (const name of backendNames) {
143
+ if (f === `${name}${ext}`) {
144
+ tryCopy(path.join(root, f), path.join(releaseDir, f));
145
+ }
146
+ }
147
+ // Metal shader library — exact filename, both platforms' build
148
+ // layouts may place it in bin/ or lib/.
149
+ if (process.platform === "darwin" && f === "default.metallib") {
150
+ tryCopy(path.join(root, f), path.join(releaseDir, "default.metallib"));
151
+ }
152
+ }
153
+ }
91
154
  }
92
155
  function isSymlink(p) {
93
156
  try {