@irsyadulibad/servermon 1.1.0 → 1.2.0

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,284 @@
1
+ import { Crust } from "@crustjs/core";
2
+ import { helpPlugin, versionPlugin, didYouMeanPlugin } from "@crustjs/plugins";
3
+ import {
4
+ loadConfig,
5
+ saveConfig,
6
+ configPath,
7
+ configFile,
8
+ listServers,
9
+ deleteConfig,
10
+ } from "../config";
11
+ import { printBanner } from "./banner";
12
+ import { serviceCmd } from "./service";
13
+ import pkg from "../../package.json";
14
+
15
+ /* ------------------------------------------------------------------ */
16
+ /* Helpers */
17
+ /* ------------------------------------------------------------------ */
18
+
19
+ async function interactiveSetup(name?: string): Promise<void> {
20
+ const tag = name ? ` [${name}]` : "";
21
+ console.log(`🖥 Server Monitor — First Time Setup${tag}`);
22
+ console.log(` Config will be saved to: ${configFile(name)}\n`);
23
+
24
+ const token = prompt("🔑 Telegram Bot Token: ")?.trim();
25
+ if (!token) {
26
+ console.error("❌ Bot token is required. Get one from @BotFather on Telegram.");
27
+ process.exit(1);
28
+ }
29
+
30
+ if (!token.includes(":")) {
31
+ console.error("❌ Invalid bot token format. Should look like: 123456:ABC-DEF1234gh...");
32
+ process.exit(1);
33
+ }
34
+
35
+ console.log("\n⏱ Choose report interval:");
36
+ console.log(" 1. Every 5 minutes");
37
+ console.log(" 2. Every 1 hour");
38
+ console.log(" 3. Every 3 hours");
39
+ console.log(" 4. Every 6 hours");
40
+ console.log(" 5. Every 12 hours");
41
+ console.log(" 6. Custom (in seconds)");
42
+
43
+ const choice = prompt(" Pick [1-6] (default: 1): ")?.trim() || "1";
44
+
45
+ const intervals: Record<string, number> = {
46
+ "1": 300,
47
+ "2": 3600,
48
+ "3": 10800,
49
+ "4": 21600,
50
+ "5": 43200,
51
+ };
52
+
53
+ let interval: number;
54
+ if (choice === "6") {
55
+ const custom = prompt(" Enter interval in seconds: ")?.trim();
56
+ interval = Math.max(30, parseInt(custom || "300") || 300);
57
+ } else {
58
+ interval = intervals[choice] ?? 300;
59
+ }
60
+
61
+ const label =
62
+ interval >= 3600
63
+ ? `${(interval / 3600).toFixed(0)} hour(s)`
64
+ : `${(interval / 60).toFixed(0)} min`;
65
+
66
+ await saveConfig({ token, interval, name });
67
+ console.log(`\n✅ Config saved!`);
68
+ console.log(` 📁 ${configFile(name)}`);
69
+ console.log(` ⏱ Interval: ${interval}s (${label})`);
70
+ const serverTag = name ? ` --name ${name}` : "";
71
+ console.log(
72
+ `\n📡 Next step: DM your bot once on Telegram, then run \`servermon start${serverTag}\`.`
73
+ );
74
+ }
75
+
76
+ /* ------------------------------------------------------------------ */
77
+ /* App definition */
78
+ /* ------------------------------------------------------------------ */
79
+
80
+ export function createApp(): Crust {
81
+ const app = new Crust("servermon")
82
+ .use(helpPlugin())
83
+ .use(versionPlugin(pkg.version))
84
+ .use(didYouMeanPlugin())
85
+ .meta({
86
+ description:
87
+ "Lightweight server monitoring daemon — collect system metrics and send structured reports to Telegram",
88
+ })
89
+ .flags({
90
+ name: {
91
+ type: "string",
92
+ description: "Server name",
93
+ short: "n",
94
+ inherit: true,
95
+ },
96
+ })
97
+ /* ---- setup ---- */
98
+ .command("setup", (cmd) =>
99
+ cmd
100
+ .meta({ description: "First-time setup (bot token + interval)" })
101
+ .run(async ({ flags }) => {
102
+ await interactiveSetup(flags.name);
103
+ })
104
+ )
105
+ /* ---- start ---- */
106
+ .command("start", (cmd) =>
107
+ cmd
108
+ .meta({ description: "Start the monitoring daemon" })
109
+ .run(async ({ flags }) => {
110
+ const name = flags.name;
111
+
112
+ if (name) {
113
+ const config = await loadConfig(name);
114
+ if (!config) {
115
+ console.error(
116
+ `❌ No config found for server "${name}". Run \`servermon setup --name ${name}\` first.`
117
+ );
118
+ process.exit(1);
119
+ }
120
+ process.env["TELEGRAM_BOT_TOKEN"] = config.token;
121
+ process.env["MONITOR_INTERVAL"] = String(config.interval);
122
+ if (config.chatId) process.env["TELEGRAM_CHAT_ID"] = config.chatId;
123
+ if (config.name) process.env["SERVER_NAME"] = config.name;
124
+
125
+ console.log(`📁 Config: ${configPath(config.name)}`);
126
+ console.log(`📡 Bot: ...${config.token.slice(-8)}`);
127
+ if (config.chatId) console.log(`💬 Chat: ${config.chatId}`);
128
+ if (config.name) console.log(`🏷 Name: ${config.name}`);
129
+ console.log();
130
+
131
+ const { start } = await import("../daemon");
132
+ await start();
133
+ } else {
134
+ console.log(" 🌐 Multi-server mode — monitoring all configured servers\n");
135
+ const { startAll } = await import("../daemon");
136
+ await startAll();
137
+ }
138
+ })
139
+ )
140
+
141
+ /* ---- report ---- */
142
+ .command("report", (cmd) =>
143
+ cmd
144
+ .meta({ description: "Send a one-time report without starting the daemon" })
145
+ .run(async ({ flags }) => {
146
+ const name = flags.name;
147
+ const config = await loadConfig(name);
148
+ if (!config) {
149
+ console.error(
150
+ name
151
+ ? `❌ No config found for server "${name}". Run \`servermon setup --name ${name}\` first.`
152
+ : "❌ No config found. Run `servermon setup` first."
153
+ );
154
+ process.exit(1);
155
+ }
156
+ if (!config.chatId) {
157
+ console.error("❌ Chat ID not set. Run `servermon start` first to auto-detect.");
158
+ process.exit(1);
159
+ }
160
+
161
+ console.log(`📤 Sending one-time report${name ? ` for [${name}]` : ""}...`);
162
+ console.log(`📡 Bot: ...${config.token.slice(-8)} 💬 ${config.chatId}\n`);
163
+
164
+ const { sendReport } = await import("../reporter");
165
+ const ok = await sendReport(config.token, config.chatId, config.name);
166
+ console.log(ok ? "✅ Report sent!" : "❌ Failed to send report");
167
+ })
168
+ )
169
+
170
+ /* ---- list ---- */
171
+ .command("list", (cmd) =>
172
+ cmd.meta({ description: "List all configured servers" }).run(async () => {
173
+ const servers = await listServers();
174
+ if (servers.length === 0) {
175
+ console.log("📭 No configured servers.");
176
+ console.log(" Run `servermon setup` or `servermon setup --name <name>` first.");
177
+ return;
178
+ }
179
+ console.log("📋 Configured Servers:");
180
+ for (const s of servers) {
181
+ const cfg = await loadConfig(s === "default" ? undefined : s);
182
+ const nameTag = cfg?.name ? ` (--name ${cfg.name})` : "";
183
+ const interval = cfg?.interval ?? "?";
184
+ const chatLabel = cfg?.chatId ? ` 💬 ${cfg.chatId}` : " ❌ not yet paired";
185
+ console.log(` • ${s}${nameTag}`);
186
+ console.log(` ⏱ ${interval}s ${chatLabel}`);
187
+ }
188
+ })
189
+ )
190
+
191
+ /* ---- delete ---- */
192
+ .command("delete", (cmd) =>
193
+ cmd
194
+ .meta({ description: "Delete a configured server" })
195
+ .args([{ name: "name", type: "string", description: "Server name" }] as const)
196
+ .flags({
197
+ yes: {
198
+ type: "boolean",
199
+ description: "Skip confirmation",
200
+ short: "y",
201
+ },
202
+ })
203
+ .run(async ({ args, flags }) => {
204
+ const rawName = args.name ?? flags.name;
205
+ if (!rawName) {
206
+ console.error("❌ Usage: servermon delete <name>");
207
+ console.log(" Use `servermon list` to see available servers.");
208
+ process.exit(1);
209
+ }
210
+
211
+ const name = rawName === "default" ? undefined : rawName;
212
+ const cfg = await loadConfig(name);
213
+ if (!cfg) {
214
+ console.error(`❌ No server found with name "${rawName}".`);
215
+ process.exit(1);
216
+ }
217
+
218
+ console.log(`⚠️ You are about to delete server config: "${rawName}"`);
219
+ console.log(` 📁 ${configFile(name)}`);
220
+ console.log(` 🤖 Bot: ...${cfg.token.slice(-8)}`);
221
+ if (cfg.chatId) console.log(` 💬 Chat: ${cfg.chatId}`);
222
+ console.log("\n This action cannot be undone.\n");
223
+
224
+ if (!flags.yes) {
225
+ const answer = prompt(" Type 'yes' to confirm: ")?.trim().toLowerCase();
226
+ if (answer !== "yes") {
227
+ console.log("❌ Cancelled.");
228
+ return;
229
+ }
230
+ }
231
+
232
+ const ok = await deleteConfig(name);
233
+ if (ok) {
234
+ console.log(`✅ Server "${rawName}" deleted.`);
235
+ } else {
236
+ console.error(`❌ Failed to delete server "${rawName}".`);
237
+ process.exit(1);
238
+ }
239
+ })
240
+ )
241
+
242
+ /* ---- service ---- */
243
+ .command("service", (cmd) => serviceCmd(cmd))
244
+
245
+ /* ---- deprecated aliases (hidden) ---- */
246
+ .command("install-service", (cmd) =>
247
+ cmd.meta({ hidden: true }).run(async () => {
248
+ console.log("ℹ️ This command is deprecated. Use: `servermon service install`");
249
+ const { serviceRouter } = await import("./service");
250
+ await serviceRouter(["install"]);
251
+ })
252
+ )
253
+ .command("--install-service", (cmd) =>
254
+ cmd.meta({ hidden: true }).run(async () => {
255
+ console.log("ℹ️ This command is deprecated. Use: `servermon service install`");
256
+ const { serviceRouter } = await import("./service");
257
+ await serviceRouter(["install"]);
258
+ })
259
+ )
260
+ .command("--setup-systemd", (cmd) =>
261
+ cmd.meta({ hidden: true }).run(async () => {
262
+ console.log("ℹ️ This command is deprecated. Use: `servermon service install`");
263
+ const { serviceRouter } = await import("./service");
264
+ await serviceRouter(["install"]);
265
+ })
266
+ );
267
+
268
+ return app;
269
+ }
270
+
271
+ /* ------------------------------------------------------------------ */
272
+ /* Main entry point */
273
+ /* ------------------------------------------------------------------ */
274
+
275
+ export async function main(): Promise<void> {
276
+ printBanner();
277
+ const app = createApp();
278
+ // No args → show help
279
+ if (process.argv.length <= 2) {
280
+ await app.execute({ argv: ["--help"] });
281
+ } else {
282
+ await app.execute();
283
+ }
284
+ }
@@ -0,0 +1,306 @@
1
+ import type { Crust } from "@crustjs/core";
2
+ import { loadConfig, configPath } from "../config";
3
+
4
+ const HOME = process.env.HOME ?? "~";
5
+ const SYSTEMD_DIR = `${HOME}/.config/systemd/user`;
6
+ const SERVICE_NAME = "servermon.service";
7
+ const SERVICE_PATH = `${SYSTEMD_DIR}/${SERVICE_NAME}`;
8
+
9
+ /* ------------------------------------------------------------------ */
10
+ /* Helpers */
11
+ /* ------------------------------------------------------------------ */
12
+
13
+ function sysctl(...args: string[]): { ok: boolean; out: string; err: string } {
14
+ const proc = Bun.spawnSync({
15
+ cmd: ["systemctl", "--user", ...args],
16
+ stdout: "pipe",
17
+ stderr: "pipe",
18
+ });
19
+ return {
20
+ ok: proc.exitCode === 0,
21
+ out: new TextDecoder().decode(proc.stdout).trim(),
22
+ err: new TextDecoder().decode(proc.stderr).trim(),
23
+ };
24
+ }
25
+
26
+ async function ensureSystemdDir(): Promise<void> {
27
+ await Bun.write(`${SYSTEMD_DIR}/.gitkeep`, "");
28
+ try {
29
+ await (
30
+ Bun as unknown as { mkdir?: (p: string, o: { recursive: boolean }) => Promise<void> }
31
+ ).mkdir?.(SYSTEMD_DIR, { recursive: true });
32
+ } catch {
33
+ /* Bun.mkdir fallback */
34
+ }
35
+ try {
36
+ await import("node:fs").then((m) => m.mkdirSync(SYSTEMD_DIR, { recursive: true }));
37
+ } catch {
38
+ /* fs fallback */
39
+ }
40
+ }
41
+
42
+ function isInstalled(): boolean {
43
+ const { out } = sysctl("is-enabled", SERVICE_NAME);
44
+ return out !== "disabled" && out !== "";
45
+ }
46
+
47
+ function getServiceStatus(): string {
48
+ const { out } = sysctl("status", SERVICE_NAME, "--no-pager", "-l");
49
+ return out;
50
+ }
51
+
52
+ /* ------------------------------------------------------------------ */
53
+ /* Subcommand handlers */
54
+ /* ------------------------------------------------------------------ */
55
+
56
+ async function cmdInstall(): Promise<void> {
57
+ const config = await loadConfig();
58
+ if (!config) {
59
+ console.error("❌ No config found. Run `servermon setup` first.");
60
+ process.exit(1);
61
+ }
62
+
63
+ const bunPath = Bun.which("bun");
64
+ if (!bunPath) {
65
+ console.error("❌ bun not found in PATH");
66
+ process.exit(1);
67
+ }
68
+
69
+ const servermonPath = Bun.which("servermon");
70
+ if (!servermonPath) {
71
+ console.error("❌ servermon binary not found. Install with: bun i -g @irsyadulibad/servermon");
72
+ process.exit(1);
73
+ }
74
+
75
+ console.log(`🔍 bun: ${bunPath}`);
76
+ console.log(`🔍 servermon: ${servermonPath}`);
77
+ console.log(`📁 Config: ${configPath()}\n`);
78
+
79
+ await ensureSystemdDir();
80
+
81
+ const serviceContent = `[Unit]
82
+ Description=Server Monitor — Telegram system health reports
83
+ After=network-online.target
84
+ Wants=network-online.target
85
+
86
+ [Service]
87
+ Type=simple
88
+ ExecStart=${servermonPath} start
89
+ Restart=always
90
+ RestartSec=30
91
+ Environment=NODE_ENV=production
92
+
93
+ [Install]
94
+ WantedBy=default.target
95
+ `;
96
+
97
+ await Bun.write(SERVICE_PATH, serviceContent);
98
+ console.log(`📄 Written: ${SERVICE_PATH}`);
99
+
100
+ const steps = [
101
+ ["daemon-reload"],
102
+ ["enable", SERVICE_NAME],
103
+ ["start", SERVICE_NAME],
104
+ ["status", SERVICE_NAME, "--no-pager", "-l"],
105
+ ];
106
+
107
+ for (const args of steps) {
108
+ const cmd = `systemctl --user ${args.join(" ")}`;
109
+ console.log(`\n🏃 ${cmd}`);
110
+ const { out, err, ok } = sysctl(...args);
111
+ if (out) console.log(out);
112
+ if (err && !ok) console.error(err);
113
+ }
114
+
115
+ console.log("\n✅ Systemd service installed and running!\n");
116
+ console.log("📌 Useful commands:");
117
+ console.log(" servermon service status # check health");
118
+ console.log(" servermon service logs # watch logs");
119
+ console.log(" servermon service stop # stop daemon");
120
+ console.log(" servermon service restart # restart daemon");
121
+ console.log(" servermon service uninstall # remove service\n");
122
+ console.log("📌 For auto-start at boot:");
123
+ console.log(" loginctl enable-linger");
124
+ }
125
+
126
+ function cmdStatus(): void {
127
+ if (!isInstalled()) {
128
+ console.log("📭 Systemd service is not installed.");
129
+ console.log(" Run `servermon service install` first.");
130
+ return;
131
+ }
132
+
133
+ const status = getServiceStatus();
134
+ console.log(status);
135
+
136
+ const { out: activeState } = sysctl("is-active", SERVICE_NAME);
137
+ const { out: enabledState } = sysctl("is-enabled", SERVICE_NAME);
138
+
139
+ const activeIcon = activeState === "active" ? "✅" : "❌";
140
+ const enabledIcon = enabledState === "enabled" ? "✅" : "❌";
141
+
142
+ console.log(`\n${activeIcon} Active: ${activeState}`);
143
+ console.log(`${enabledIcon} Enabled: ${enabledState}`);
144
+ }
145
+
146
+ function cmdStop(): void {
147
+ console.log("🛑 Stopping servermon service...");
148
+ const { ok, out, err } = sysctl("stop", SERVICE_NAME);
149
+ if (out) console.log(out);
150
+ if (err) console.error(err);
151
+ console.log(ok ? "✅ Service stopped." : "⚠️ Could not stop service.");
152
+ }
153
+
154
+ function cmdRestart(): void {
155
+ console.log("🔄 Restarting servermon service...");
156
+ const { ok, out, err } = sysctl("restart", SERVICE_NAME);
157
+ if (out) console.log(out);
158
+ if (err) console.error(err);
159
+ console.log(ok ? "✅ Service restarted." : "⚠️ Could not restart service.");
160
+ }
161
+
162
+ function cmdLogs(): void {
163
+ if (!isInstalled()) {
164
+ console.log("📭 Service not installed.");
165
+ return;
166
+ }
167
+
168
+ const args = ["--user", "-u", SERVICE_NAME, "-f", "-n", "50"];
169
+ const proc = Bun.spawn(["journalctl", ...args], { stdio: ["inherit", "inherit", "inherit"] });
170
+ proc.exited.then((code) => {
171
+ if (code !== 0 && code !== null) process.exit(code);
172
+ });
173
+ }
174
+
175
+ async function cmdUninstall(): Promise<void> {
176
+ console.log("🗑 Uninstalling servermon service...");
177
+
178
+ const steps = [
179
+ { args: ["stop", SERVICE_NAME], label: "Stopping" },
180
+ { args: ["disable", SERVICE_NAME], label: "Disabling" },
181
+ ];
182
+
183
+ for (const { args, label } of steps) {
184
+ const { ok, err } = sysctl(...args);
185
+ if (err) console.error(`⚠️ ${label}: ${err}`);
186
+ if (ok) console.log(`✅ ${label}`);
187
+ }
188
+
189
+ try {
190
+ await import("fs/promises").then((m) => m.unlink(SERVICE_PATH));
191
+ console.log("✅ Service file removed");
192
+ } catch {
193
+ console.log("⚠️ Service file not found or already removed");
194
+ }
195
+
196
+ sysctl("daemon-reload");
197
+ console.log("✅ Daemon reloaded");
198
+ console.log("\n✅ Service uninstalled.");
199
+ }
200
+
201
+ /* ------------------------------------------------------------------ */
202
+ /* CrustJS service command (exported for use in index.ts) */
203
+ /* ------------------------------------------------------------------ */
204
+
205
+ export const serviceCmd: Parameters<Crust["command"]>[1] = (cmd) =>
206
+ cmd
207
+ .meta({ description: "Manage systemd service" })
208
+ .command("install", (sub) =>
209
+ sub
210
+ .meta({ description: "Install systemd service & start" })
211
+ .run(async () => {
212
+ await cmdInstall();
213
+ })
214
+ )
215
+ .command("status", (sub) =>
216
+ sub
217
+ .meta({ description: "Check service health" })
218
+ .run(() => {
219
+ cmdStatus();
220
+ })
221
+ )
222
+ .command("stop", (sub) =>
223
+ sub
224
+ .meta({ description: "Stop the service" })
225
+ .run(() => {
226
+ cmdStop();
227
+ })
228
+ )
229
+ .command("restart", (sub) =>
230
+ sub
231
+ .meta({ description: "Restart the service" })
232
+ .run(() => {
233
+ cmdRestart();
234
+ })
235
+ )
236
+ .command("logs", (sub) =>
237
+ sub
238
+ .meta({ description: "Follow real-time logs" })
239
+ .run(() => {
240
+ cmdLogs();
241
+ })
242
+ )
243
+ .command("uninstall", (sub) =>
244
+ sub
245
+ .meta({ description: "Stop, disable, & remove service" })
246
+ .flags({
247
+ yes: {
248
+ type: "boolean",
249
+ description: "Skip confirmation",
250
+ short: "y",
251
+ },
252
+ })
253
+ .run(async ({ flags }) => {
254
+ if (!flags.yes) {
255
+ console.log("⚠️ This will stop and remove the servermon service.");
256
+ console.log(" To confirm, run: `servermon service uninstall --yes`");
257
+ return;
258
+ }
259
+ await cmdUninstall();
260
+ })
261
+ );
262
+
263
+ /* ------------------------------------------------------------------ */
264
+ /* Legacy router (kept for backward compat) */
265
+ /* ------------------------------------------------------------------ */
266
+
267
+ export async function serviceRouter(subs?: string[]): Promise<void> {
268
+ const sub = subs?.[0] ?? process.argv[3];
269
+
270
+ switch (sub) {
271
+ case "install":
272
+ await cmdInstall();
273
+ break;
274
+ case "status":
275
+ cmdStatus();
276
+ break;
277
+ case "stop":
278
+ cmdStop();
279
+ break;
280
+ case "restart":
281
+ cmdRestart();
282
+ break;
283
+ case "logs":
284
+ cmdLogs();
285
+ break;
286
+ case "uninstall":
287
+ await cmdUninstall();
288
+ break;
289
+ default:
290
+ console.log("Usage: servermon service <subcommand>");
291
+ console.log();
292
+ console.log("Subcommands:");
293
+ console.log(" install Install systemd service & start");
294
+ console.log(" status Check service health");
295
+ console.log(" stop Stop the service");
296
+ console.log(" restart Restart the service");
297
+ console.log(" logs Follow real-time logs");
298
+ console.log(" uninstall [--yes] Stop, disable, & remove service");
299
+ console.log();
300
+ console.log("Examples:");
301
+ console.log(" servermon service install");
302
+ console.log(" servermon service status");
303
+ console.log(" servermon service logs");
304
+ console.log(" servermon service uninstall --yes");
305
+ }
306
+ }
@@ -0,0 +1,84 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { mkdir, readdir, unlink } from "fs/promises";
4
+ import { existsSync } from "fs";
5
+ import type { ServerMonConfig } from "../types";
6
+
7
+ const CONFIG_DIR = join(homedir(), ".irsyadulibad", "servermon");
8
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
9
+
10
+ /* ------------------------------------------------------------------ */
11
+ /* Helpers */
12
+ /* ------------------------------------------------------------------ */
13
+
14
+ export function configFile(name?: string): string {
15
+ return name ? join(CONFIG_DIR, `config-${name}.json`) : CONFIG_FILE;
16
+ }
17
+
18
+ export function configPath(name?: string): string {
19
+ return configFile(name);
20
+ }
21
+
22
+ export function configDir(): string {
23
+ return CONFIG_DIR;
24
+ }
25
+
26
+ /* ------------------------------------------------------------------ */
27
+ /* CRUD */
28
+ /* ------------------------------------------------------------------ */
29
+
30
+ export async function ensureConfigDir(): Promise<void> {
31
+ await mkdir(CONFIG_DIR, { recursive: true });
32
+ }
33
+
34
+ export async function loadConfig(name?: string): Promise<ServerMonConfig | null> {
35
+ try {
36
+ const file = Bun.file(configFile(name));
37
+ if (!(await file.exists())) return null;
38
+ const data = await file.json();
39
+ if (!data?.token) return null;
40
+ return {
41
+ token: String(data.token),
42
+ interval: Math.max(30, parseInt(String(data.interval)) || 300),
43
+ chatId: data.chatId ? String(data.chatId) : undefined,
44
+ name: data.name ? String(data.name) : name,
45
+ };
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export async function saveConfig(config: ServerMonConfig): Promise<void> {
52
+ await ensureConfigDir();
53
+ await Bun.write(configFile(config.name), JSON.stringify(config, null, 2));
54
+ }
55
+
56
+ export async function deleteConfig(name?: string): Promise<boolean> {
57
+ const file = configFile(name);
58
+ if (!existsSync(file)) return false;
59
+ try {
60
+ await unlink(file);
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ /* ------------------------------------------------------------------ */
68
+ /* Inventory */
69
+ /* ------------------------------------------------------------------ */
70
+
71
+ export async function listServers(): Promise<string[]> {
72
+ const names: string[] = [];
73
+ try {
74
+ const entries = await readdir(CONFIG_DIR);
75
+ for (const entry of entries) {
76
+ const match = entry.match(/^config-(.+)\.json$/);
77
+ if (match) names.push(match[1]!);
78
+ }
79
+ } catch {
80
+ // dir may not exist yet
81
+ }
82
+ if (existsSync(CONFIG_FILE)) names.unshift("default");
83
+ return names;
84
+ }