@chvor/cli 0.1.1 → 0.1.2

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/dist/cli.js CHANGED
@@ -28,6 +28,8 @@ program
28
28
  .option("-i, --instance <name>", "Named instance to start")
29
29
  .action(async (opts) => {
30
30
  if (opts.instance) {
31
+ const { validateInstanceName } = await import("./lib/validate.js");
32
+ validateInstanceName(opts.instance);
31
33
  const { setInstance } = await import("./lib/paths.js");
32
34
  setInstance(opts.instance);
33
35
  }
@@ -44,6 +46,8 @@ program
44
46
  .option("-i, --instance <name>", "Named instance to stop")
45
47
  .action(async (opts) => {
46
48
  if (opts.instance) {
49
+ const { validateInstanceName } = await import("./lib/validate.js");
50
+ validateInstanceName(opts.instance);
47
51
  const { setInstance } = await import("./lib/paths.js");
48
52
  setInstance(opts.instance);
49
53
  }
@@ -72,6 +76,8 @@ instancesCmd
72
76
  .command("start <name>")
73
77
  .description("Start a named instance")
74
78
  .action(async (name) => {
79
+ const { validateInstanceName } = await import("./lib/validate.js");
80
+ validateInstanceName(name);
75
81
  const { startInstance } = await import("./commands/instances.js");
76
82
  await startInstance(name);
77
83
  });
@@ -79,6 +85,8 @@ instancesCmd
79
85
  .command("stop <name>")
80
86
  .description("Stop a named instance")
81
87
  .action(async (name) => {
88
+ const { validateInstanceName } = await import("./lib/validate.js");
89
+ validateInstanceName(name);
82
90
  const { stopInstance } = await import("./commands/instances.js");
83
91
  await stopInstance(name);
84
92
  });
@@ -208,6 +216,52 @@ toolCmd
208
216
  const { toolPublish } = await import("./commands/skill.js");
209
217
  await toolPublish(path);
210
218
  });
219
+ program
220
+ .command("open")
221
+ .description("Open chvor in your default browser")
222
+ .action(async () => {
223
+ const { open } = await import("./commands/open.js");
224
+ await open();
225
+ });
226
+ const serviceCmd = program
227
+ .command("service")
228
+ .description("Manage auto-start on login");
229
+ serviceCmd
230
+ .command("install")
231
+ .description("Enable auto-start on login")
232
+ .option("-i, --instance <name>", "Named instance")
233
+ .action(async (opts) => {
234
+ if (opts.instance) {
235
+ const { validateInstanceName } = await import("./lib/validate.js");
236
+ validateInstanceName(opts.instance);
237
+ }
238
+ const { serviceInstall } = await import("./commands/service.js");
239
+ await serviceInstall(opts);
240
+ });
241
+ serviceCmd
242
+ .command("uninstall")
243
+ .description("Disable auto-start on login")
244
+ .option("-i, --instance <name>", "Named instance")
245
+ .action(async (opts) => {
246
+ if (opts.instance) {
247
+ const { validateInstanceName } = await import("./lib/validate.js");
248
+ validateInstanceName(opts.instance);
249
+ }
250
+ const { serviceUninstall } = await import("./commands/service.js");
251
+ await serviceUninstall(opts);
252
+ });
253
+ serviceCmd
254
+ .command("status")
255
+ .description("Check auto-start status")
256
+ .option("-i, --instance <name>", "Named instance")
257
+ .action(async (opts) => {
258
+ if (opts.instance) {
259
+ const { validateInstanceName } = await import("./lib/validate.js");
260
+ validateInstanceName(opts.instance);
261
+ }
262
+ const { serviceStatus } = await import("./commands/service.js");
263
+ await serviceStatus(opts);
264
+ });
211
265
  const authCmd = program
212
266
  .command("auth")
213
267
  .description("Manage authentication");
@@ -217,6 +271,8 @@ authCmd
217
271
  .option("-i, --instance <name>", "Named instance to reset")
218
272
  .action(async (opts) => {
219
273
  if (opts.instance) {
274
+ const { validateInstanceName } = await import("./lib/validate.js");
275
+ validateInstanceName(opts.instance);
220
276
  const { setInstance } = await import("./lib/paths.js");
221
277
  setInstance(opts.instance);
222
278
  }
@@ -39,46 +39,68 @@ export async function onboard() {
39
39
  const version = pkg.version;
40
40
  await downloadRelease(version);
41
41
  await spawnServer({ port });
42
- await pollHealth(port, token);
43
- const providerNames = {
44
- anthropic: "Anthropic",
45
- openai: "OpenAI",
46
- "google-ai": "Google AI",
47
- };
48
- const credRes = await fetch(`http://localhost:${port}/api/credentials`, {
49
- method: "POST",
50
- headers: {
51
- "Content-Type": "application/json",
52
- Authorization: `Bearer ${token}`,
53
- },
54
- body: JSON.stringify({
55
- name: providerNames[provider],
56
- type: provider,
57
- data: { apiKey },
58
- }),
59
- });
60
- if (!credRes.ok) {
61
- console.warn(`Warning: failed to save credentials (${credRes.status}). You can add them later in the UI.`);
42
+ // spawnServer already polls health internally — check once more briefly
43
+ // to decide whether we can configure credentials via API
44
+ const serverReady = await pollHealth(port, token, 5000);
45
+ if (serverReady) {
46
+ const providerNames = {
47
+ anthropic: "Anthropic",
48
+ openai: "OpenAI",
49
+ "google-ai": "Google AI",
50
+ };
51
+ try {
52
+ const credRes = await fetch(`http://localhost:${port}/api/credentials`, {
53
+ method: "POST",
54
+ headers: {
55
+ "Content-Type": "application/json",
56
+ Authorization: `Bearer ${token}`,
57
+ },
58
+ body: JSON.stringify({
59
+ name: providerNames[provider],
60
+ type: provider,
61
+ data: { apiKey },
62
+ }),
63
+ });
64
+ if (!credRes.ok) {
65
+ console.warn(`Warning: failed to save credentials (${credRes.status}). You can add them later in the UI.`);
66
+ }
67
+ }
68
+ catch {
69
+ console.warn("Warning: could not reach server to save credentials. You can add them later in the UI.");
70
+ }
71
+ try {
72
+ const personaRes = await fetch(`http://localhost:${port}/api/persona`, {
73
+ method: "PATCH",
74
+ headers: {
75
+ "Content-Type": "application/json",
76
+ Authorization: `Bearer ${token}`,
77
+ },
78
+ body: JSON.stringify({
79
+ name: userName,
80
+ timezone,
81
+ onboarded: true,
82
+ }),
83
+ });
84
+ if (!personaRes.ok) {
85
+ console.warn(`Warning: failed to save persona (${personaRes.status}). You can update it later in the UI.`);
86
+ }
87
+ }
88
+ catch {
89
+ console.warn("Warning: could not reach server to save persona. You can update it later in the UI.");
90
+ }
62
91
  }
63
- const personaRes = await fetch(`http://localhost:${port}/api/persona`, {
64
- method: "PATCH",
65
- headers: {
66
- "Content-Type": "application/json",
67
- Authorization: `Bearer ${token}`,
68
- },
69
- body: JSON.stringify({
70
- name: userName,
71
- timezone,
72
- onboarded: true,
73
- }),
74
- });
75
- if (!personaRes.ok) {
76
- console.warn(`Warning: failed to save persona (${personaRes.status}). You can update it later in the UI.`);
92
+ else {
93
+ console.warn("\n Server is still starting up. Your config has been saved." +
94
+ "\n Credentials and persona will be configured when you open the UI.");
77
95
  }
78
96
  console.log(`\n chvor is running at http://localhost:${port}`);
79
97
  console.log(" Open this URL in your browser to get started.\n");
80
98
  console.log(" Useful commands:");
81
- console.log(" chvor stop Stop the server");
82
- console.log(" chvor start Start the server");
83
- console.log(" chvor update Update to latest version\n");
99
+ console.log(" chvor stop Stop the server");
100
+ console.log(" chvor start Start the server");
101
+ console.log(" chvor open Open chvor in your browser");
102
+ console.log(" chvor service install Start automatically on login");
103
+ console.log(" chvor update Update to latest version\n");
104
+ console.log(" Tip: For system tray, auto-updates, and no terminal needed,");
105
+ console.log(" try the desktop app: https://github.com/luka-zivkovic/chvor/releases/latest\n");
84
106
  }
@@ -0,0 +1,19 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readConfig } from "../lib/config.js";
3
+ export async function open() {
4
+ const config = readConfig();
5
+ const port = config.port ?? "3001";
6
+ const url = `http://localhost:${port}`;
7
+ switch (process.platform) {
8
+ case "darwin":
9
+ execFileSync("open", [url]);
10
+ break;
11
+ case "win32":
12
+ execFileSync("cmd", ["/c", "start", "", url]);
13
+ break;
14
+ default:
15
+ execFileSync("xdg-open", [url]);
16
+ break;
17
+ }
18
+ console.log(`Opened ${url}`);
19
+ }
@@ -0,0 +1,36 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { isOnboarded } from "../lib/config.js";
3
+ async function getPlatformModule() {
4
+ switch (process.platform) {
5
+ case "darwin":
6
+ return import("../lib/service-darwin.js");
7
+ case "linux":
8
+ return import("../lib/service-linux.js");
9
+ case "win32":
10
+ return import("../lib/service-win32.js");
11
+ default:
12
+ throw new Error(`Unsupported platform: ${process.platform}`);
13
+ }
14
+ }
15
+ function resolveExecPaths() {
16
+ const nodePath = process.execPath;
17
+ const cliPath = realpathSync(process.argv[1]);
18
+ return { nodePath, cliPath };
19
+ }
20
+ export async function serviceInstall(opts) {
21
+ if (!isOnboarded()) {
22
+ console.error("Run `chvor onboard` first.");
23
+ process.exit(1);
24
+ }
25
+ const mod = await getPlatformModule();
26
+ const { nodePath, cliPath } = resolveExecPaths();
27
+ await mod.install(nodePath, cliPath, opts.instance);
28
+ }
29
+ export async function serviceUninstall(opts) {
30
+ const mod = await getPlatformModule();
31
+ await mod.uninstall(opts.instance);
32
+ }
33
+ export async function serviceStatus(opts) {
34
+ const mod = await getPlatformModule();
35
+ await mod.status(opts.instance);
36
+ }
@@ -1,4 +1,27 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
1
4
  import { stopServer } from "../lib/process.js";
5
+ function isServiceInstalled() {
6
+ switch (process.platform) {
7
+ case "darwin":
8
+ return existsSync(join(homedir(), "Library", "LaunchAgents", "ai.chvor.server.plist"));
9
+ case "linux":
10
+ return existsSync(join(homedir(), ".config", "systemd", "user", "chvor.service"));
11
+ case "win32": {
12
+ const appData = process.env.APPDATA;
13
+ if (!appData)
14
+ return false;
15
+ return existsSync(join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "chvor-autostart.vbs"));
16
+ }
17
+ default:
18
+ return false;
19
+ }
20
+ }
2
21
  export async function stop() {
3
22
  await stopServer();
23
+ if (isServiceInstalled()) {
24
+ console.log("Note: Auto-start is configured. Server will restart on next login.");
25
+ console.log(" Use `chvor service uninstall` to disable auto-start.");
26
+ }
4
27
  }
@@ -1,9 +1,13 @@
1
- import { createWriteStream, createReadStream, existsSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { createWriteStream, createReadStream, existsSync, rmSync, readdirSync, realpathSync } from "node:fs";
2
+ import { join, resolve, sep } from "node:path";
3
3
  import { pipeline } from "node:stream/promises";
4
4
  import { Readable } from "node:stream";
5
5
  import { createHash } from "node:crypto";
6
6
  import { execFileSync } from "node:child_process";
7
+ import { homedir } from "node:os";
8
+ function escapePsPath(p) {
9
+ return p.replace(/'/g, "''");
10
+ }
7
11
  import { getAppDir, getDownloadsDir, ensureDir } from "./paths.js";
8
12
  import { readConfig, writeConfig } from "./config.js";
9
13
  import { getAssetName, getPlatform } from "./platform.js";
@@ -65,7 +69,6 @@ export async function downloadRelease(version) {
65
69
  const assetName = getAssetName(version);
66
70
  const tarballPath = join(downloadsDir, assetName);
67
71
  // Download the tarball
68
- console.log(`Downloading ${assetName}...`);
69
72
  const res = await fetch(url, {
70
73
  headers: { "User-Agent": "chvor-cli" },
71
74
  });
@@ -75,6 +78,9 @@ export async function downloadRelease(version) {
75
78
  if (!res.body) {
76
79
  throw new Error("Download response has no body");
77
80
  }
81
+ const contentLength = res.headers.get("content-length");
82
+ const sizeMB = contentLength ? `${Math.round(Number(contentLength) / 1024 / 1024)} MB` : "";
83
+ console.log(`Downloading ${assetName}${sizeMB ? ` (${sizeMB})` : ""}...`);
78
84
  const fileStream = createWriteStream(tarballPath);
79
85
  await pipeline(Readable.fromWeb(res.body), fileStream);
80
86
  console.log("Download complete.");
@@ -87,25 +93,37 @@ export async function downloadRelease(version) {
87
93
  }
88
94
  console.log("Checksum verified.");
89
95
  }
90
- // Extract
96
+ // Extract — wipe previous install to avoid conflicts (Windows Move-Item
97
+ // cannot overwrite existing directories even with -Force)
91
98
  const appDir = getAppDir();
99
+ if (existsSync(appDir)) {
100
+ // Safety: resolve symlinks and verify the target is under the user's home
101
+ const realAppDir = realpathSync(appDir);
102
+ const realHome = resolve(homedir());
103
+ // Case-insensitive comparison on Windows where paths are case-insensitive
104
+ const norm = (p) => process.platform === "win32" ? p.toLowerCase() : p;
105
+ if (!norm(realAppDir).startsWith(norm(realHome) + sep)) {
106
+ throw new Error(`Refusing to delete path outside home directory: ${realAppDir}`);
107
+ }
108
+ rmSync(appDir, { recursive: true, force: true });
109
+ }
92
110
  ensureDir(appDir);
93
111
  console.log(`Extracting to ${appDir}...`);
94
112
  if (getPlatform() === "win") {
95
113
  execFileSync("powershell", [
96
114
  "-NoProfile", "-Command",
97
- `Expand-Archive -Path '${tarballPath}' -DestinationPath '${appDir}' -Force`,
115
+ `Expand-Archive -Path '${escapePsPath(tarballPath)}' -DestinationPath '${escapePsPath(appDir)}' -Force`,
98
116
  ], { stdio: "inherit" });
99
117
  // Move contents up from the nested directory (strip-components equivalent)
100
118
  const nested = join(appDir, assetName.replace(/\.zip$/, ""));
101
119
  if (existsSync(nested)) {
102
120
  execFileSync("powershell", [
103
121
  "-NoProfile", "-Command",
104
- `Get-ChildItem -Path '${nested}' | Move-Item -Destination '${appDir}' -Force`,
122
+ `Get-ChildItem -Path '${escapePsPath(nested)}' | Move-Item -Destination '${escapePsPath(appDir)}' -Force`,
105
123
  ], { stdio: "inherit" });
106
124
  execFileSync("powershell", [
107
125
  "-NoProfile", "-Command",
108
- `Remove-Item -Path '${nested}' -Recurse -Force`,
126
+ `Remove-Item -Path '${escapePsPath(nested)}' -Recurse -Force`,
109
127
  ], { stdio: "inherit" });
110
128
  }
111
129
  }
@@ -115,24 +133,40 @@ export async function downloadRelease(version) {
115
133
  });
116
134
  }
117
135
  console.log("Extraction complete.");
118
- // Install Playwright's Chromium browser (required by browser agent / Stagehand)
119
- console.log("Installing browser engine (Chromium)...");
136
+ // Note: Playwright Chromium is installed lazily on first web-agent use,
137
+ // not during initial setup, to keep install fast.
138
+ // Update config
139
+ const config = readConfig();
140
+ config.installedVersion = version;
141
+ writeConfig(config);
142
+ console.log(`Chvor v${version} installed successfully.`);
143
+ }
144
+ export function ensurePlaywright() {
145
+ const appDir = getAppDir();
146
+ const playwrightCli = join(appDir, "node_modules", "@playwright", "test", "cli.js");
147
+ if (!existsSync(playwrightCli))
148
+ return false;
149
+ // Check if Chromium is already installed by looking for the local browsers dir.
150
+ // Playwright stores downloaded browsers under playwright-core/.local-browsers/
151
+ const localBrowsers = join(appDir, "node_modules", "playwright-core", ".local-browsers");
152
+ const alreadyInstalled = existsSync(localBrowsers) &&
153
+ (readdirSync(localBrowsers).some((entry) => entry.toLowerCase().includes("chromium")));
154
+ if (alreadyInstalled)
155
+ return true;
120
156
  try {
121
- execFileSync("node", [
122
- join(appDir, "node_modules", "@playwright", "test", "cli.js"),
123
- "install", "chromium",
124
- ], { stdio: "inherit", cwd: appDir });
157
+ console.log("Installing browser engine (Chromium) for web agent...");
158
+ execFileSync("node", [playwrightCli, "install", "chromium"], {
159
+ stdio: "inherit",
160
+ cwd: appDir,
161
+ });
125
162
  console.log("Browser engine installed.");
163
+ return true;
126
164
  }
127
- catch (err) {
165
+ catch {
128
166
  console.warn("Warning: failed to install browser engine. " +
129
167
  "The web agent won't work until you run: npx playwright install chromium");
168
+ return false;
130
169
  }
131
- // Update config
132
- const config = readConfig();
133
- config.installedVersion = version;
134
- writeConfig(config);
135
- console.log(`Chvor v${version} installed successfully.`);
136
170
  }
137
171
  async function computeSha256(filePath) {
138
172
  return new Promise((resolve, reject) => {
@@ -101,18 +101,38 @@ export async function spawnServer(opts = {}) {
101
101
  env,
102
102
  stdio: "inherit",
103
103
  });
104
+ if (child.pid === undefined) {
105
+ throw new Error("Failed to spawn server process.");
106
+ }
104
107
  const pidPath = getPidPath();
105
- writeFileSync(pidPath, String(child.pid), "utf-8");
108
+ writeFileSync(pidPath, String(child.pid), { encoding: "utf-8", mode: 0o600 });
109
+ child.on("error", (err) => {
110
+ console.error(`Server process error: ${err.message}`);
111
+ try {
112
+ unlinkSync(pidPath);
113
+ }
114
+ catch { /* ignore */ }
115
+ process.exit(1);
116
+ });
117
+ // Forward SIGTERM/SIGINT to child so service managers (launchd, systemd)
118
+ // see a clean exit(0) instead of 128+signal
119
+ for (const sig of ["SIGTERM", "SIGINT"]) {
120
+ process.on(sig, () => {
121
+ try {
122
+ unlinkSync(pidPath);
123
+ }
124
+ catch { /* ignore */ }
125
+ child.kill(sig);
126
+ });
127
+ }
106
128
  child.on("exit", (code) => {
107
129
  try {
108
130
  unlinkSync(pidPath);
109
131
  }
110
132
  catch {
111
- // ignore
112
- }
113
- if (code !== 0) {
114
- console.error(`Chvor exited with code ${code}`);
133
+ // ignore — may already be cleaned up by signal handler
115
134
  }
135
+ process.exit(code ?? 1);
116
136
  });
117
137
  // Wait for health
118
138
  const healthy = await pollHealth(port, token);
@@ -134,9 +154,12 @@ export async function spawnServer(opts = {}) {
134
154
  stdio: ["ignore", logFd, logFd],
135
155
  detached: true,
136
156
  });
157
+ if (child.pid === undefined) {
158
+ throw new Error("Failed to spawn server process.");
159
+ }
137
160
  child.unref();
138
161
  const pidPath = getPidPath();
139
- writeFileSync(pidPath, String(child.pid), "utf-8");
162
+ writeFileSync(pidPath, String(child.pid), { encoding: "utf-8", mode: 0o600 });
140
163
  console.log(`Chvor started (PID ${child.pid}). Logs: ${logPath}`);
141
164
  const healthy = await pollHealth(port, token);
142
165
  if (healthy) {
@@ -182,7 +205,7 @@ export async function stopServer() {
182
205
  }
183
206
  console.log("Chvor stopped.");
184
207
  }
185
- export async function pollHealth(port, token, timeoutMs = 15000, intervalMs = 500) {
208
+ export async function pollHealth(port, token, timeoutMs = 30000, intervalMs = 500) {
186
209
  const start = Date.now();
187
210
  const url = `http://localhost:${port}/api/health`;
188
211
  const headers = {};
@@ -0,0 +1,104 @@
1
+ import { writeFileSync, unlinkSync, existsSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ function escapeXml(s) {
6
+ return s
7
+ .replace(/&/g, "&amp;")
8
+ .replace(/</g, "&lt;")
9
+ .replace(/>/g, "&gt;")
10
+ .replace(/"/g, "&quot;")
11
+ .replace(/'/g, "&apos;");
12
+ }
13
+ function getPlistPath(instance) {
14
+ const name = instance ? `ai.chvor.server.${instance}` : "ai.chvor.server";
15
+ return join(homedir(), "Library", "LaunchAgents", `${name}.plist`);
16
+ }
17
+ function getLabel(instance) {
18
+ return instance ? `ai.chvor.server.${instance}` : "ai.chvor.server";
19
+ }
20
+ function getUid() {
21
+ return execFileSync("id", ["-u"], { encoding: "utf-8" }).trim();
22
+ }
23
+ export async function install(nodePath, cliPath, instance) {
24
+ const plistPath = getPlistPath(instance);
25
+ const label = getLabel(instance);
26
+ const args = ["start", "--foreground"];
27
+ if (instance)
28
+ args.push("-i", instance);
29
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
30
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
31
+ <plist version="1.0">
32
+ <dict>
33
+ <key>Label</key>
34
+ <string>${escapeXml(label)}</string>
35
+ <key>ProgramArguments</key>
36
+ <array>
37
+ <string>${escapeXml(nodePath)}</string>
38
+ <string>${escapeXml(cliPath)}</string>
39
+ ${args.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n")}
40
+ </array>
41
+ <key>RunAtLoad</key>
42
+ <true/>
43
+ <key>KeepAlive</key>
44
+ <dict>
45
+ <key>SuccessfulExit</key>
46
+ <false/>
47
+ </dict>
48
+ <key>StandardOutPath</key>
49
+ <string>${escapeXml(join(homedir(), ".chvor", "logs", "launchd-stdout.log"))}</string>
50
+ <key>StandardErrorPath</key>
51
+ <string>${escapeXml(join(homedir(), ".chvor", "logs", "launchd-stderr.log"))}</string>
52
+ </dict>
53
+ </plist>`;
54
+ writeFileSync(plistPath, plist, "utf-8");
55
+ const uid = getUid();
56
+ try {
57
+ execFileSync("launchctl", ["bootout", `gui/${uid}/${label}`], { stdio: "pipe" });
58
+ }
59
+ catch {
60
+ // Not loaded yet — fine
61
+ }
62
+ execFileSync("launchctl", ["bootstrap", `gui/${uid}`, plistPath], { stdio: "inherit" });
63
+ console.log(`Auto-start installed. Chvor will start on login.`);
64
+ console.log(` Plist: ${plistPath}`);
65
+ }
66
+ export async function uninstall(instance) {
67
+ const plistPath = getPlistPath(instance);
68
+ const label = getLabel(instance);
69
+ if (!existsSync(plistPath)) {
70
+ console.log("Auto-start is not installed.");
71
+ return;
72
+ }
73
+ const uid = getUid();
74
+ try {
75
+ execFileSync("launchctl", ["bootout", `gui/${uid}/${label}`], { stdio: "pipe" });
76
+ }
77
+ catch {
78
+ // Already unloaded
79
+ }
80
+ unlinkSync(plistPath);
81
+ console.log("Auto-start removed.");
82
+ }
83
+ export async function status(instance) {
84
+ const plistPath = getPlistPath(instance);
85
+ if (!existsSync(plistPath)) {
86
+ console.log("Auto-start: not installed");
87
+ return;
88
+ }
89
+ const label = getLabel(instance);
90
+ const uid = getUid();
91
+ try {
92
+ const output = execFileSync("launchctl", ["print", `gui/${uid}/${label}`], {
93
+ encoding: "utf-8",
94
+ stdio: ["pipe", "pipe", "pipe"],
95
+ });
96
+ const running = output.includes("state = running");
97
+ console.log(`Auto-start: installed (${running ? "running" : "stopped"})`);
98
+ console.log(` Plist: ${plistPath}`);
99
+ }
100
+ catch {
101
+ console.log("Auto-start: installed (not loaded)");
102
+ console.log(` Plist: ${plistPath}`);
103
+ }
104
+ }
@@ -0,0 +1,86 @@
1
+ import { writeFileSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ function getServicePath(instance) {
6
+ const name = instance ? `chvor-${instance}` : "chvor";
7
+ return join(homedir(), ".config", "systemd", "user", `${name}.service`);
8
+ }
9
+ function getServiceName(instance) {
10
+ return instance ? `chvor-${instance}.service` : "chvor.service";
11
+ }
12
+ function escapeSystemdArg(s) {
13
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
14
+ }
15
+ export async function install(nodePath, cliPath, instance) {
16
+ const servicePath = getServicePath(instance);
17
+ const serviceName = getServiceName(instance);
18
+ mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
19
+ const args = ["start", "--foreground"];
20
+ if (instance)
21
+ args.push("-i", instance);
22
+ const execArgs = [`"${escapeSystemdArg(nodePath)}"`, `"${escapeSystemdArg(cliPath)}"`, ...args.map((a) => `"${escapeSystemdArg(a)}"`)].join(" ");
23
+ const unit = `[Unit]
24
+ Description=Chvor AI Server${instance ? ` (${instance})` : ""}
25
+ After=network.target
26
+
27
+ [Service]
28
+ Type=simple
29
+ ExecStart=${execArgs}
30
+ Restart=on-failure
31
+ RestartSec=5
32
+ Environment=NODE_ENV=production
33
+
34
+ [Install]
35
+ WantedBy=default.target
36
+ `;
37
+ writeFileSync(servicePath, unit, "utf-8");
38
+ execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
39
+ execFileSync("systemctl", ["--user", "enable", serviceName], { stdio: "inherit" });
40
+ execFileSync("systemctl", ["--user", "start", serviceName], { stdio: "inherit" });
41
+ console.log(`Auto-start installed. Chvor will start on login.`);
42
+ console.log(` Service: ${servicePath}`);
43
+ }
44
+ export async function uninstall(instance) {
45
+ const servicePath = getServicePath(instance);
46
+ const serviceName = getServiceName(instance);
47
+ if (!existsSync(servicePath)) {
48
+ console.log("Auto-start is not installed.");
49
+ return;
50
+ }
51
+ try {
52
+ execFileSync("systemctl", ["--user", "stop", serviceName], { stdio: "pipe" });
53
+ }
54
+ catch {
55
+ // Already stopped
56
+ }
57
+ try {
58
+ execFileSync("systemctl", ["--user", "disable", serviceName], { stdio: "pipe" });
59
+ }
60
+ catch {
61
+ // Already disabled
62
+ }
63
+ unlinkSync(servicePath);
64
+ execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
65
+ console.log("Auto-start removed.");
66
+ }
67
+ export async function status(instance) {
68
+ const servicePath = getServicePath(instance);
69
+ const serviceName = getServiceName(instance);
70
+ if (!existsSync(servicePath)) {
71
+ console.log("Auto-start: not installed");
72
+ return;
73
+ }
74
+ try {
75
+ const output = execFileSync("systemctl", ["--user", "is-active", serviceName], {
76
+ encoding: "utf-8",
77
+ stdio: ["pipe", "pipe", "pipe"],
78
+ });
79
+ console.log(`Auto-start: installed (${output.trim()})`);
80
+ console.log(` Service: ${servicePath}`);
81
+ }
82
+ catch {
83
+ console.log("Auto-start: installed (inactive)");
84
+ console.log(` Service: ${servicePath}`);
85
+ }
86
+ }
@@ -0,0 +1,43 @@
1
+ import { writeFileSync, unlinkSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ function getVbsPath(instance) {
4
+ const appData = process.env.APPDATA;
5
+ if (!appData)
6
+ throw new Error("APPDATA environment variable not set");
7
+ const name = instance ? `chvor-${instance}-autostart` : "chvor-autostart";
8
+ return join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${name}.vbs`);
9
+ }
10
+ function escapeVbs(s) {
11
+ return s.replace(/"/g, '""');
12
+ }
13
+ export async function install(nodePath, cliPath, instance) {
14
+ const vbsPath = getVbsPath(instance);
15
+ const escapedNode = escapeVbs(nodePath);
16
+ const escapedCli = escapeVbs(cliPath);
17
+ const args = instance ? `start -i ""${escapeVbs(instance)}""` : "start";
18
+ // VBScript that runs chvor start in a hidden window (no console flash)
19
+ const vbs = `Set WshShell = CreateObject("WScript.Shell")
20
+ WshShell.Run """${escapedNode}"" ""${escapedCli}"" ${args}", 0, False
21
+ `;
22
+ writeFileSync(vbsPath, vbs, "utf-8");
23
+ console.log(`Auto-start installed. Chvor will start on login.`);
24
+ console.log(` Script: ${vbsPath}`);
25
+ }
26
+ export async function uninstall(instance) {
27
+ const vbsPath = getVbsPath(instance);
28
+ if (!existsSync(vbsPath)) {
29
+ console.log("Auto-start is not installed.");
30
+ return;
31
+ }
32
+ unlinkSync(vbsPath);
33
+ console.log("Auto-start removed.");
34
+ }
35
+ export async function status(instance) {
36
+ const vbsPath = getVbsPath(instance);
37
+ if (!existsSync(vbsPath)) {
38
+ console.log("Auto-start: not installed");
39
+ return;
40
+ }
41
+ console.log("Auto-start: installed");
42
+ console.log(` Script: ${vbsPath}`);
43
+ }
@@ -5,3 +5,9 @@ export function validatePort(value) {
5
5
  }
6
6
  return value;
7
7
  }
8
+ export function validateInstanceName(name) {
9
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name)) {
10
+ throw new Error(`Invalid instance name "${name}". Use only letters, digits, hyphens, and underscores (max 64 chars, must start with alphanumeric).`);
11
+ }
12
+ return name;
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chvor/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Your own AI — install and run chvor.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "type": "module",