@chvor/cli 0.1.1 → 0.1.3
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 +56 -0
- package/dist/commands/onboard.js +59 -37
- package/dist/commands/open.js +19 -0
- package/dist/commands/service.js +36 -0
- package/dist/commands/stop.js +23 -0
- package/dist/lib/download.js +53 -19
- package/dist/lib/process.js +33 -10
- package/dist/lib/process.test.js +71 -0
- package/dist/lib/service-darwin.js +104 -0
- package/dist/lib/service-linux.js +86 -0
- package/dist/lib/service-win32.js +43 -0
- package/dist/lib/validate.js +6 -0
- package/package.json +7 -2
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
|
}
|
package/dist/commands/onboard.js
CHANGED
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
82
|
-
console.log(" chvor start
|
|
83
|
-
console.log(" chvor
|
|
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
|
+
}
|
package/dist/commands/stop.js
CHANGED
|
@@ -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
|
}
|
package/dist/lib/download.js
CHANGED
|
@@ -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
|
-
//
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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) => {
|
package/dist/lib/process.js
CHANGED
|
@@ -97,22 +97,42 @@ export async function spawnServer(opts = {}) {
|
|
|
97
97
|
}
|
|
98
98
|
if (opts.foreground) {
|
|
99
99
|
console.log(`Starting Chvor on port ${port} (foreground)...`);
|
|
100
|
-
const child = spawn("node", [serverEntry], {
|
|
100
|
+
const child = spawn("node", ["--import", "tsx", serverEntry], {
|
|
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);
|
|
@@ -129,14 +149,17 @@ export async function spawnServer(opts = {}) {
|
|
|
129
149
|
ensureDir(logsDir);
|
|
130
150
|
const logPath = join(logsDir, "server.log");
|
|
131
151
|
const logFd = openSync(logPath, "a");
|
|
132
|
-
const child = spawn("node", [serverEntry], {
|
|
152
|
+
const child = spawn("node", ["--import", "tsx", serverEntry], {
|
|
133
153
|
env,
|
|
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 =
|
|
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 = {};
|
|
@@ -209,7 +232,7 @@ export async function pollHealth(port, token, timeoutMs = 15000, intervalMs = 50
|
|
|
209
232
|
function sleep(ms) {
|
|
210
233
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
211
234
|
}
|
|
212
|
-
function filterEnv(env) {
|
|
235
|
+
export function filterEnv(env) {
|
|
213
236
|
const result = {};
|
|
214
237
|
for (const [key, value] of Object.entries(env)) {
|
|
215
238
|
if (value !== undefined) {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { filterEnv, pollHealth } from "./process.js";
|
|
3
|
+
describe("filterEnv", () => {
|
|
4
|
+
it("removes entries with undefined values", () => {
|
|
5
|
+
const input = { A: "1", B: undefined, C: "3" };
|
|
6
|
+
const result = filterEnv(input);
|
|
7
|
+
expect(result).toEqual({ A: "1", C: "3" });
|
|
8
|
+
});
|
|
9
|
+
it("returns empty object for empty input", () => {
|
|
10
|
+
expect(filterEnv({})).toEqual({});
|
|
11
|
+
});
|
|
12
|
+
it("keeps all entries when none are undefined", () => {
|
|
13
|
+
const input = { X: "x", Y: "y" };
|
|
14
|
+
expect(filterEnv(input)).toEqual({ X: "x", Y: "y" });
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
describe("pollHealth", () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
20
|
+
vi.stubGlobal("fetch", vi.fn());
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.useRealTimers();
|
|
24
|
+
vi.restoreAllMocks();
|
|
25
|
+
});
|
|
26
|
+
it("returns true when server responds with ok: true", async () => {
|
|
27
|
+
const mockFetch = vi.mocked(fetch);
|
|
28
|
+
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
29
|
+
const result = await pollHealth("3001");
|
|
30
|
+
expect(result).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
it("sends Authorization header when token is provided", async () => {
|
|
33
|
+
const mockFetch = vi.mocked(fetch);
|
|
34
|
+
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
35
|
+
await pollHealth("3001", "my-secret-token");
|
|
36
|
+
expect(mockFetch).toHaveBeenCalledWith("http://localhost:3001/api/health", { headers: { Authorization: "Bearer my-secret-token" } });
|
|
37
|
+
});
|
|
38
|
+
it("retries on fetch failure and succeeds eventually", async () => {
|
|
39
|
+
const mockFetch = vi.mocked(fetch);
|
|
40
|
+
mockFetch
|
|
41
|
+
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
|
|
42
|
+
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
|
|
43
|
+
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
44
|
+
const result = await pollHealth("3001", undefined, 30000, 100);
|
|
45
|
+
expect(result).toBe(true);
|
|
46
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
47
|
+
});
|
|
48
|
+
it("returns false when timeout expires", async () => {
|
|
49
|
+
const mockFetch = vi.mocked(fetch);
|
|
50
|
+
mockFetch.mockRejectedValue(new Error("ECONNREFUSED"));
|
|
51
|
+
const result = await pollHealth("3001", undefined, 1000, 200);
|
|
52
|
+
expect(result).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
it("retries when response is not ok", async () => {
|
|
55
|
+
const mockFetch = vi.mocked(fetch);
|
|
56
|
+
mockFetch
|
|
57
|
+
.mockResolvedValueOnce(new Response("Internal Server Error", { status: 500 }))
|
|
58
|
+
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
59
|
+
const result = await pollHealth("3001", undefined, 30000, 100);
|
|
60
|
+
expect(result).toBe(true);
|
|
61
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
62
|
+
});
|
|
63
|
+
it("retries when body.ok is not true", async () => {
|
|
64
|
+
const mockFetch = vi.mocked(fetch);
|
|
65
|
+
mockFetch
|
|
66
|
+
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: false }), { status: 200 }))
|
|
67
|
+
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
68
|
+
const result = await pollHealth("3001", undefined, 30000, 100);
|
|
69
|
+
expect(result).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -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, "&")
|
|
8
|
+
.replace(/</g, "<")
|
|
9
|
+
.replace(/>/g, ">")
|
|
10
|
+
.replace(/"/g, """)
|
|
11
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|
package/dist/lib/validate.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Your own AI — install and run chvor.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"type": "module",
|
|
@@ -17,16 +17,21 @@
|
|
|
17
17
|
"scripts": {
|
|
18
18
|
"build": "tsc",
|
|
19
19
|
"prepublishOnly": "tsc",
|
|
20
|
-
"typecheck": "tsc --noEmit"
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"test": "vitest run"
|
|
21
22
|
},
|
|
22
23
|
"dependencies": {
|
|
23
24
|
"commander": "^13",
|
|
24
25
|
"@inquirer/prompts": "^7",
|
|
26
|
+
"tsx": "^4",
|
|
25
27
|
"yaml": "^2"
|
|
26
28
|
},
|
|
27
29
|
"optionalDependencies": {
|
|
28
30
|
"better-sqlite3": "^11"
|
|
29
31
|
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"vitest": "^4.1.0"
|
|
34
|
+
},
|
|
30
35
|
"repository": {
|
|
31
36
|
"type": "git",
|
|
32
37
|
"url": "https://github.com/luka-zivkovic/chvor.git",
|