valheim-oz-dsm 1.5.1 → 1.6.1

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/main.js CHANGED
@@ -1196,6 +1196,11 @@ function getPlatform() {
1196
1196
  if (os === "darwin") return "darwin";
1197
1197
  return "linux";
1198
1198
  }
1199
+ function getSteamPlatform() {
1200
+ const platform = getPlatform();
1201
+ if (platform === "darwin") return "macos";
1202
+ return platform;
1203
+ }
1199
1204
  function getHomeDir() {
1200
1205
  const platform = getPlatform();
1201
1206
  if (platform === "windows") {
@@ -1690,9 +1695,11 @@ function parseValue(value) {
1690
1695
  }
1691
1696
 
1692
1697
  // src/cli/commands/doctor.ts
1698
+ import { exec as exec2 } from "child_process";
1693
1699
  import fs3 from "fs/promises";
1694
1700
  import net from "net";
1695
1701
  import path4 from "path";
1702
+ import { promisify as promisify2 } from "util";
1696
1703
  import steamcmd4 from "@caleb-collar/steamcmd";
1697
1704
 
1698
1705
  // src/steamcmd/mod.ts
@@ -1925,8 +1932,11 @@ async function installValheim(onProgress) {
1925
1932
  message: "Starting Valheim installation..."
1926
1933
  });
1927
1934
  try {
1935
+ const platform = getSteamPlatform();
1928
1936
  await steamcmd3.install({
1929
1937
  applicationId: VALHEIM_APP_ID,
1938
+ platform,
1939
+ // Explicitly set platform to ensure correct server binaries are downloaded
1930
1940
  onProgress: (p) => {
1931
1941
  const stage = mapPhaseToStage2(p.phase);
1932
1942
  const progress = p.percent ?? 0;
@@ -2005,6 +2015,7 @@ async function getInstalledVersion() {
2005
2015
  }
2006
2016
 
2007
2017
  // src/cli/commands/doctor.ts
2018
+ var execAsync2 = promisify2(exec2);
2008
2019
  async function exists(filePath) {
2009
2020
  try {
2010
2021
  await fs3.access(filePath);
@@ -2171,6 +2182,69 @@ async function checkPermissions() {
2171
2182
  };
2172
2183
  }
2173
2184
  }
2185
+ async function checkLinux32BitLibs() {
2186
+ const platform = getPlatform();
2187
+ if (platform !== "linux") {
2188
+ return {
2189
+ name: "32-bit Libraries (Linux)",
2190
+ status: "pass",
2191
+ message: "Not applicable on this platform"
2192
+ };
2193
+ }
2194
+ try {
2195
+ const { stdout: dpkgCheck } = await execAsync2("which dpkg 2>/dev/null");
2196
+ if (!dpkgCheck.trim()) {
2197
+ return {
2198
+ name: "32-bit Libraries (Linux)",
2199
+ status: "pass",
2200
+ message: "Non-Debian system detected, assuming dependencies are met"
2201
+ };
2202
+ }
2203
+ const { stdout: archCheck } = await execAsync2(
2204
+ "dpkg --print-foreign-architectures 2>/dev/null"
2205
+ );
2206
+ const hasI386 = archCheck.includes("i386");
2207
+ if (!hasI386) {
2208
+ return {
2209
+ name: "32-bit Libraries (Linux)",
2210
+ status: "fail",
2211
+ message: "i386 architecture not enabled. Run: sudo dpkg --add-architecture i386 && sudo apt update",
2212
+ fixable: false
2213
+ };
2214
+ }
2215
+ const requiredPackages = ["lib32gcc-s1", "lib32stdc++6", "libc6:i386"];
2216
+ const missingPackages = [];
2217
+ for (const pkg of requiredPackages) {
2218
+ try {
2219
+ const { stdout } = await execAsync2(`dpkg -s ${pkg} 2>/dev/null`);
2220
+ if (!stdout.includes("Status: install ok installed")) {
2221
+ missingPackages.push(pkg);
2222
+ }
2223
+ } catch {
2224
+ missingPackages.push(pkg);
2225
+ }
2226
+ }
2227
+ if (missingPackages.length > 0) {
2228
+ return {
2229
+ name: "32-bit Libraries (Linux)",
2230
+ status: "fail",
2231
+ message: `Missing packages: ${missingPackages.join(", ")}. Install with: sudo apt install ${missingPackages.join(" ")}`,
2232
+ fixable: false
2233
+ };
2234
+ }
2235
+ return {
2236
+ name: "32-bit Libraries (Linux)",
2237
+ status: "pass",
2238
+ message: "All required 32-bit libraries are installed"
2239
+ };
2240
+ } catch (error2) {
2241
+ return {
2242
+ name: "32-bit Libraries (Linux)",
2243
+ status: "warn",
2244
+ message: `Could not verify 32-bit libraries: ${error2}`
2245
+ };
2246
+ }
2247
+ }
2174
2248
  async function runAllChecks() {
2175
2249
  const checks = [];
2176
2250
  checks.push(await checkSteamCmd());
@@ -2179,6 +2253,7 @@ async function runAllChecks() {
2179
2253
  checks.push(await checkSaveDirectory());
2180
2254
  checks.push(await checkPorts());
2181
2255
  checks.push(await checkPermissions());
2256
+ checks.push(await checkLinux32BitLibs());
2182
2257
  const summary = {
2183
2258
  passed: checks.filter((c) => c.status === "pass").length,
2184
2259
  warnings: checks.filter((c) => c.status === "warn").length,
@@ -2569,6 +2644,25 @@ import * as fs5 from "fs/promises";
2569
2644
  import { dirname, join } from "path";
2570
2645
 
2571
2646
  // src/server/logs.ts
2647
+ function parseLogLine(line) {
2648
+ const timestamp = /* @__PURE__ */ new Date();
2649
+ let level = "info";
2650
+ let message = line.trim();
2651
+ if (line.includes("Error") || line.includes("Exception")) {
2652
+ level = "error";
2653
+ } else if (line.includes("Warning") || line.includes("WARN")) {
2654
+ level = "warn";
2655
+ } else if (line.includes("DEBUG") || line.includes("[Debug]")) {
2656
+ level = "debug";
2657
+ }
2658
+ const timestampMatch = line.match(
2659
+ /^(\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}:\d{2}): (.+)$/
2660
+ );
2661
+ if (timestampMatch) {
2662
+ message = timestampMatch[2];
2663
+ }
2664
+ return { timestamp, level, message, raw: line };
2665
+ }
2572
2666
  function parseEvent(line) {
2573
2667
  if (line.includes("Got character ZDOID from")) {
2574
2668
  const match = line.match(/Got character ZDOID from (\S+)/);
@@ -2615,9 +2709,179 @@ function parseEvent(line) {
2615
2709
  return null;
2616
2710
  }
2617
2711
 
2712
+ // src/server/logTail.ts
2713
+ import { open } from "fs/promises";
2714
+ var LogTailer = class {
2715
+ filePath;
2716
+ handle = null;
2717
+ position = 0;
2718
+ running = false;
2719
+ pollInterval = null;
2720
+ buffer = "";
2721
+ onLine;
2722
+ onEvent;
2723
+ pollMs;
2724
+ /**
2725
+ * Creates a new log tailer
2726
+ * @param filePath Path to the log file to tail
2727
+ * @param onLine Callback for each new log line
2728
+ * @param options Optional settings
2729
+ */
2730
+ constructor(filePath, onLine, options) {
2731
+ this.filePath = filePath;
2732
+ this.onLine = onLine;
2733
+ this.onEvent = options?.onEvent;
2734
+ this.pollMs = options?.pollMs ?? 500;
2735
+ }
2736
+ /**
2737
+ * Starts tailing the log file
2738
+ * @param fromEnd If true, start from end of file (skip existing content)
2739
+ */
2740
+ async start(fromEnd = true) {
2741
+ if (this.running) return;
2742
+ try {
2743
+ this.handle = await open(this.filePath, "r");
2744
+ if (fromEnd) {
2745
+ const stats = await this.handle.stat();
2746
+ this.position = stats.size;
2747
+ } else {
2748
+ this.position = 0;
2749
+ }
2750
+ this.running = true;
2751
+ this.pollInterval = setInterval(() => this.poll(), this.pollMs);
2752
+ await this.poll();
2753
+ } catch (_error) {
2754
+ this.running = true;
2755
+ this.pollInterval = setInterval(() => this.poll(), this.pollMs);
2756
+ }
2757
+ }
2758
+ /**
2759
+ * Stops tailing the log file
2760
+ */
2761
+ async stop() {
2762
+ this.running = false;
2763
+ if (this.pollInterval) {
2764
+ clearInterval(this.pollInterval);
2765
+ this.pollInterval = null;
2766
+ }
2767
+ if (this.handle) {
2768
+ await this.handle.close();
2769
+ this.handle = null;
2770
+ }
2771
+ }
2772
+ /**
2773
+ * Polls the log file for new content
2774
+ */
2775
+ async poll() {
2776
+ if (!this.running) return;
2777
+ try {
2778
+ if (!this.handle) {
2779
+ try {
2780
+ this.handle = await open(this.filePath, "r");
2781
+ const stats2 = await this.handle.stat();
2782
+ if (this.position > stats2.size) {
2783
+ this.position = 0;
2784
+ }
2785
+ } catch {
2786
+ return;
2787
+ }
2788
+ }
2789
+ const stats = await this.handle.stat();
2790
+ if (stats.size <= this.position) {
2791
+ if (stats.size < this.position) {
2792
+ this.position = 0;
2793
+ }
2794
+ return;
2795
+ }
2796
+ const bytesToRead = stats.size - this.position;
2797
+ const buffer = Buffer.alloc(bytesToRead);
2798
+ const { bytesRead } = await this.handle.read(
2799
+ buffer,
2800
+ 0,
2801
+ bytesToRead,
2802
+ this.position
2803
+ );
2804
+ if (bytesRead > 0) {
2805
+ this.position += bytesRead;
2806
+ this.processChunk(buffer.toString("utf-8", 0, bytesRead));
2807
+ }
2808
+ } catch (_error) {
2809
+ if (this.handle) {
2810
+ try {
2811
+ await this.handle.close();
2812
+ } catch {
2813
+ }
2814
+ this.handle = null;
2815
+ }
2816
+ }
2817
+ }
2818
+ /**
2819
+ * Processes a chunk of log data, splitting into lines
2820
+ */
2821
+ processChunk(chunk) {
2822
+ this.buffer += chunk;
2823
+ const lines = this.buffer.split("\n");
2824
+ this.buffer = lines.pop() ?? "";
2825
+ for (const line of lines) {
2826
+ const trimmed = line.trim();
2827
+ if (!trimmed) continue;
2828
+ const entry = parseLogLine(trimmed);
2829
+ this.onLine(trimmed, entry);
2830
+ const event = parseEvent(trimmed);
2831
+ if (event && this.onEvent) {
2832
+ this.onEvent(event);
2833
+ }
2834
+ }
2835
+ }
2836
+ /**
2837
+ * Reads the last N lines from the log file (for initial display)
2838
+ * @param lineCount Number of lines to read
2839
+ * @returns Array of log entries
2840
+ */
2841
+ async readLastLines(lineCount = 100) {
2842
+ const entries = [];
2843
+ try {
2844
+ const handle = await open(this.filePath, "r");
2845
+ const stats = await handle.stat();
2846
+ const fileSize = stats.size;
2847
+ const chunkSize = Math.min(16384, fileSize);
2848
+ let position = Math.max(0, fileSize - chunkSize);
2849
+ let content = "";
2850
+ let lines = [];
2851
+ while (lines.length < lineCount && position >= 0) {
2852
+ const buffer = Buffer.alloc(Math.min(chunkSize, fileSize - position));
2853
+ await handle.read(buffer, 0, buffer.length, position);
2854
+ content = buffer.toString("utf-8") + content;
2855
+ lines = content.split("\n").filter((l) => l.trim());
2856
+ if (position === 0) break;
2857
+ position = Math.max(0, position - chunkSize);
2858
+ }
2859
+ await handle.close();
2860
+ const lastLines = lines.slice(-lineCount);
2861
+ for (const line of lastLines) {
2862
+ entries.push(parseLogLine(line.trim()));
2863
+ }
2864
+ } catch {
2865
+ }
2866
+ return entries;
2867
+ }
2868
+ /** Whether the tailer is currently running */
2869
+ get isRunning() {
2870
+ return this.running;
2871
+ }
2872
+ };
2873
+
2618
2874
  // src/server/pidfile.ts
2619
2875
  import fs6 from "fs/promises";
2620
2876
  import path5 from "path";
2877
+ function getServerLogsDir() {
2878
+ return path5.join(getAppConfigDir(), "logs");
2879
+ }
2880
+ function getServerLogFile(timestamp) {
2881
+ const ts = timestamp ?? /* @__PURE__ */ new Date();
2882
+ const dateStr = ts.toISOString().split("T")[0];
2883
+ return path5.join(getServerLogsDir(), `valheim-server-${dateStr}.log`);
2884
+ }
2621
2885
  function getPidFilePath() {
2622
2886
  return path5.join(getConfigDir(), "oz-valheim", "server.pid");
2623
2887
  }
@@ -2671,9 +2935,28 @@ async function getRunningServer() {
2671
2935
  }
2672
2936
  return data;
2673
2937
  }
2938
+ async function ensureLogsDir() {
2939
+ const logsDir = getServerLogsDir();
2940
+ await fs6.mkdir(logsDir, { recursive: true });
2941
+ }
2942
+ async function cleanupOldLogs(keepCount = 7) {
2943
+ const logsDir = getServerLogsDir();
2944
+ try {
2945
+ const files = await fs6.readdir(logsDir);
2946
+ const logFiles = files.filter((f) => f.startsWith("valheim-server-") && f.endsWith(".log")).sort().reverse();
2947
+ for (const file of logFiles.slice(keepCount)) {
2948
+ try {
2949
+ await fs6.unlink(path5.join(logsDir, file));
2950
+ } catch {
2951
+ }
2952
+ }
2953
+ } catch {
2954
+ }
2955
+ }
2674
2956
 
2675
2957
  // src/server/process.ts
2676
2958
  import { spawn } from "child_process";
2959
+ import { createWriteStream } from "fs";
2677
2960
  var defaultEvents = {
2678
2961
  onStateChange: () => {
2679
2962
  },
@@ -2692,6 +2975,12 @@ var ValheimProcess = class {
2692
2975
  events;
2693
2976
  config;
2694
2977
  startTime = null;
2978
+ logFileStream = null;
2979
+ logTailer = null;
2980
+ _logFilePath = null;
2981
+ _isDetached = false;
2982
+ /** PID for detached processes (when we don't have a direct handle) */
2983
+ _detachedPid = null;
2695
2984
  /**
2696
2985
  * Creates a new Valheim process wrapper
2697
2986
  * @param config Server launch configuration
@@ -2703,7 +2992,7 @@ var ValheimProcess = class {
2703
2992
  }
2704
2993
  /** Gets the process ID if running, null otherwise */
2705
2994
  get pid() {
2706
- return this.process?.pid ?? null;
2995
+ return this.process?.pid ?? this._detachedPid ?? null;
2707
2996
  }
2708
2997
  /** Gets the current process state */
2709
2998
  get currentState() {
@@ -2713,6 +3002,14 @@ var ValheimProcess = class {
2713
3002
  get uptime() {
2714
3003
  return this.startTime;
2715
3004
  }
3005
+ /** Gets the log file path (for detached mode) */
3006
+ get logFilePath() {
3007
+ return this._logFilePath;
3008
+ }
3009
+ /** Whether the server is running in detached mode */
3010
+ get isDetached() {
3011
+ return this._isDetached;
3012
+ }
2716
3013
  /**
2717
3014
  * Updates the process state and notifies listeners
2718
3015
  * @param newState New process state
@@ -2731,21 +3028,16 @@ var ValheimProcess = class {
2731
3028
  }
2732
3029
  this.setState("starting");
2733
3030
  this.startTime = /* @__PURE__ */ new Date();
3031
+ this._isDetached = this.config.detached ?? false;
2734
3032
  const execPath = getValheimExecutablePath();
2735
3033
  const args = this.buildArgs();
2736
3034
  const env = this.getEnvironment();
2737
3035
  try {
2738
- this.process = spawn(execPath, args, {
2739
- stdio: ["ignore", "pipe", "pipe"],
2740
- env
2741
- });
2742
- this.streamOutput();
2743
- this.process.on("error", (error2) => {
2744
- this.setState("crashed");
2745
- this.startTime = null;
2746
- this.events.onError(error2);
2747
- });
2748
- await Promise.resolve();
3036
+ if (this._isDetached) {
3037
+ await this.startDetached(execPath, args, env);
3038
+ } else {
3039
+ await this.startAttached(execPath, args, env);
3040
+ }
2749
3041
  } catch (error2) {
2750
3042
  this.setState("crashed");
2751
3043
  this.startTime = null;
@@ -2753,6 +3045,169 @@ var ValheimProcess = class {
2753
3045
  throw error2;
2754
3046
  }
2755
3047
  }
3048
+ /**
3049
+ * Starts the server in attached mode (piped stdout/stderr)
3050
+ */
3051
+ async startAttached(execPath, args, env) {
3052
+ this.process = spawn(execPath, args, {
3053
+ stdio: ["ignore", "pipe", "pipe"],
3054
+ env
3055
+ });
3056
+ this.streamOutput();
3057
+ this.process.on("error", (error2) => {
3058
+ this.setState("crashed");
3059
+ this.startTime = null;
3060
+ this.events.onError(error2);
3061
+ });
3062
+ await Promise.resolve();
3063
+ }
3064
+ /**
3065
+ * Starts the server in detached mode (log file output, independent process)
3066
+ */
3067
+ async startDetached(execPath, args, env) {
3068
+ await ensureLogsDir();
3069
+ this._logFilePath = getServerLogFile(this.startTime);
3070
+ this.logFileStream = createWriteStream(this._logFilePath, { flags: "a" });
3071
+ const header = `
3072
+ ${"=".repeat(60)}
3073
+ Server starting at ${this.startTime.toISOString()}
3074
+ World: ${this.config.world} | Port: ${this.config.port}
3075
+ ${"=".repeat(60)}
3076
+ `;
3077
+ this.logFileStream.write(header);
3078
+ const platform = getPlatform();
3079
+ this.process = spawn(execPath, args, {
3080
+ stdio: ["ignore", "pipe", "pipe"],
3081
+ env,
3082
+ detached: true
3083
+ });
3084
+ if (this.process.stdout) {
3085
+ this.process.stdout.pipe(this.logFileStream, { end: false });
3086
+ }
3087
+ if (this.process.stderr) {
3088
+ this.process.stderr.pipe(this.logFileStream, { end: false });
3089
+ }
3090
+ this.logTailer = new LogTailer(
3091
+ this._logFilePath,
3092
+ (line, _entry) => {
3093
+ this.events.onLog(line);
3094
+ },
3095
+ {
3096
+ onEvent: (event) => this.handleEvent(event),
3097
+ fromEnd: false
3098
+ // Read from where we started
3099
+ }
3100
+ );
3101
+ await this.logTailer.start(false);
3102
+ this.process.on("error", (error2) => {
3103
+ this.setState("crashed");
3104
+ this.startTime = null;
3105
+ this.events.onError(error2);
3106
+ });
3107
+ const pid = this.process.pid;
3108
+ if (pid) {
3109
+ await writePidFile({
3110
+ pid,
3111
+ startedAt: this.startTime.toISOString(),
3112
+ world: this.config.world,
3113
+ port: this.config.port,
3114
+ logFile: this._logFilePath,
3115
+ detached: true,
3116
+ serverName: this.config.name
3117
+ });
3118
+ }
3119
+ this.process.unref();
3120
+ if (platform === "windows") {
3121
+ if (this.process.stdout && typeof this.process.stdout.unref === "function") {
3122
+ this.process.stdout.unref();
3123
+ }
3124
+ if (this.process.stderr && typeof this.process.stderr.unref === "function") {
3125
+ this.process.stderr.unref();
3126
+ }
3127
+ }
3128
+ this.process.on("exit", (code, signal) => {
3129
+ const exitCode = code ?? (signal ? 1 : 0);
3130
+ if (exitCode !== 0 && (this.state === "online" || this.state === "starting")) {
3131
+ this.startTime = null;
3132
+ this.setState("crashed");
3133
+ this.events.onError(new Error(`Server exited with code ${exitCode}`));
3134
+ }
3135
+ });
3136
+ await Promise.resolve();
3137
+ }
3138
+ /**
3139
+ * Handles a parsed event from log output
3140
+ */
3141
+ handleEvent(event) {
3142
+ this.events.onEvent?.(event);
3143
+ switch (event.type) {
3144
+ case "player_join":
3145
+ this.events.onPlayerJoin(event.name);
3146
+ break;
3147
+ case "player_leave":
3148
+ this.events.onPlayerLeave(event.name);
3149
+ break;
3150
+ case "server_ready":
3151
+ if (this.state === "starting") {
3152
+ this.setState("online");
3153
+ }
3154
+ break;
3155
+ case "error":
3156
+ this.events.onError(new Error(event.message));
3157
+ break;
3158
+ }
3159
+ }
3160
+ /**
3161
+ * Attaches to an already-running detached server
3162
+ * @param pidData PID file data for the running server
3163
+ */
3164
+ async attach(pidData) {
3165
+ if (this.state !== "offline" && this.state !== "crashed") {
3166
+ throw new Error(`Cannot attach in state: ${this.state}`);
3167
+ }
3168
+ if (!isProcessRunning(pidData.pid)) {
3169
+ throw new Error(`Server process ${pidData.pid} is not running`);
3170
+ }
3171
+ this._isDetached = true;
3172
+ this._detachedPid = pidData.pid;
3173
+ this._logFilePath = pidData.logFile ?? null;
3174
+ this.startTime = new Date(pidData.startedAt);
3175
+ if (this._logFilePath) {
3176
+ this.logTailer = new LogTailer(
3177
+ this._logFilePath,
3178
+ (line, _entry) => {
3179
+ this.events.onLog(line);
3180
+ },
3181
+ {
3182
+ onEvent: (event) => this.handleEvent(event),
3183
+ pollMs: 500
3184
+ }
3185
+ );
3186
+ const history = await this.logTailer.readLastLines(50);
3187
+ for (const entry of history) {
3188
+ this.events.onLog(entry.raw);
3189
+ }
3190
+ await this.logTailer.start(true);
3191
+ }
3192
+ this.startProcessMonitor();
3193
+ this.setState("online");
3194
+ }
3195
+ /**
3196
+ * Monitors a detached process for exit
3197
+ */
3198
+ startProcessMonitor() {
3199
+ const checkInterval = setInterval(() => {
3200
+ const pid = this._detachedPid;
3201
+ if (pid && !isProcessRunning(pid)) {
3202
+ clearInterval(checkInterval);
3203
+ this._detachedPid = null;
3204
+ this.startTime = null;
3205
+ this.setState("crashed");
3206
+ this.events.onError(new Error("Server process exited unexpectedly"));
3207
+ }
3208
+ }, 2e3);
3209
+ this._monitorInterval = checkInterval;
3210
+ }
2756
3211
  /**
2757
3212
  * Gracefully stops the server with optional timeout
2758
3213
  * @param timeout Maximum time to wait for graceful shutdown (ms)
@@ -2762,6 +3217,31 @@ var ValheimProcess = class {
2762
3217
  return;
2763
3218
  }
2764
3219
  this.setState("stopping");
3220
+ await this.cleanup();
3221
+ if (this._isDetached && this._detachedPid) {
3222
+ const platform = getPlatform();
3223
+ try {
3224
+ process.kill(this._detachedPid, "SIGTERM");
3225
+ } catch {
3226
+ }
3227
+ const startTime = Date.now();
3228
+ while (isProcessRunning(this._detachedPid) && Date.now() - startTime < timeout) {
3229
+ await new Promise((resolve) => setTimeout(resolve, 500));
3230
+ }
3231
+ if (isProcessRunning(this._detachedPid)) {
3232
+ try {
3233
+ process.kill(
3234
+ this._detachedPid,
3235
+ platform === "windows" ? "SIGTERM" : "SIGKILL"
3236
+ );
3237
+ } catch {
3238
+ }
3239
+ }
3240
+ this._detachedPid = null;
3241
+ this.startTime = null;
3242
+ this.setState("offline");
3243
+ return;
3244
+ }
2765
3245
  if (this.process) {
2766
3246
  try {
2767
3247
  this.process.kill("SIGTERM");
@@ -2789,6 +3269,18 @@ var ValheimProcess = class {
2789
3269
  * Immediately kills the server process
2790
3270
  */
2791
3271
  async kill() {
3272
+ await this.cleanup();
3273
+ if (this._isDetached && this._detachedPid) {
3274
+ const platform = getPlatform();
3275
+ try {
3276
+ process.kill(
3277
+ this._detachedPid,
3278
+ platform === "windows" ? "SIGTERM" : "SIGKILL"
3279
+ );
3280
+ } catch {
3281
+ }
3282
+ this._detachedPid = null;
3283
+ }
2792
3284
  if (this.process) {
2793
3285
  try {
2794
3286
  this.process.kill("SIGKILL");
@@ -2800,6 +3292,36 @@ var ValheimProcess = class {
2800
3292
  this.setState("offline");
2801
3293
  await Promise.resolve();
2802
3294
  }
3295
+ /**
3296
+ * Detaches from a running server without stopping it
3297
+ * Only valid for servers started in detached mode
3298
+ */
3299
+ async detach() {
3300
+ if (!this._isDetached) {
3301
+ throw new Error("Cannot detach from non-detached server");
3302
+ }
3303
+ await this.cleanup();
3304
+ this.process = null;
3305
+ this._detachedPid = null;
3306
+ }
3307
+ /**
3308
+ * Cleans up resources (log tailer, monitor interval, log file stream)
3309
+ */
3310
+ async cleanup() {
3311
+ const self = this;
3312
+ if (self._monitorInterval) {
3313
+ clearInterval(self._monitorInterval);
3314
+ self._monitorInterval = void 0;
3315
+ }
3316
+ if (this.logTailer) {
3317
+ await this.logTailer.stop();
3318
+ this.logTailer = null;
3319
+ }
3320
+ if (this.logFileStream) {
3321
+ this.logFileStream.end();
3322
+ this.logFileStream = null;
3323
+ }
3324
+ }
2803
3325
  /**
2804
3326
  * Builds command line arguments for Valheim server
2805
3327
  * @returns Array of command line arguments
@@ -3115,6 +3637,18 @@ async function startCommand(args, config) {
3115
3637
  console.log("Run 'valheim-dsm install' first to install the server.");
3116
3638
  process.exit(1);
3117
3639
  }
3640
+ const running = await getRunningServer();
3641
+ if (running) {
3642
+ console.error(`
3643
+ Error: A server is already running.`);
3644
+ console.log(` PID: ${running.pid}`);
3645
+ console.log(` World: ${running.world}`);
3646
+ console.log(` Port: ${running.port}`);
3647
+ console.log(` Started: ${new Date(running.startedAt).toLocaleString()}`);
3648
+ console.log("\nRun 'valheim-dsm stop' to stop it first.");
3649
+ process.exit(1);
3650
+ }
3651
+ await cleanupOldLogs();
3118
3652
  const serverConfig = {
3119
3653
  name: args.name ?? config.server.name,
3120
3654
  port: args.port ?? config.server.port,
@@ -3124,7 +3658,9 @@ async function startCommand(args, config) {
3124
3658
  crossplay: args.crossplay ?? config.server.crossplay,
3125
3659
  savedir: args.savedir ?? config.server.savedir,
3126
3660
  saveinterval: config.server.saveinterval,
3127
- backups: config.server.backups
3661
+ backups: config.server.backups,
3662
+ // Always use detached mode for stability
3663
+ detached: true
3128
3664
  };
3129
3665
  console.log(`
3130
3666
  Starting ${serverConfig.name}...`);
@@ -3132,6 +3668,7 @@ Starting ${serverConfig.name}...`);
3132
3668
  console.log(` Port: ${serverConfig.port}`);
3133
3669
  console.log(` Public: ${serverConfig.public}`);
3134
3670
  console.log(` Crossplay: ${serverConfig.crossplay}`);
3671
+ console.log(` Mode: Detached (server continues after terminal exits)`);
3135
3672
  console.log("");
3136
3673
  activeWatchdog = new Watchdog(
3137
3674
  serverConfig,
@@ -3145,6 +3682,12 @@ Starting ${serverConfig.name}...`);
3145
3682
  {
3146
3683
  onStateChange: (state) => {
3147
3684
  console.log(`[Server] State: ${state}`);
3685
+ if (state === "online") {
3686
+ console.log("\n\u2713 Server is now online!");
3687
+ console.log(" The server will continue running in the background.");
3688
+ console.log(" Use 'valheim-dsm stop' to stop it.");
3689
+ console.log(" Use 'valheim-dsm' (TUI) to manage it.\n");
3690
+ }
3148
3691
  },
3149
3692
  onLog: (line) => {
3150
3693
  console.log(`[Server] ${line}`);
@@ -3173,18 +3716,34 @@ Starting ${serverConfig.name}...`);
3173
3716
  setupShutdownHandlers();
3174
3717
  try {
3175
3718
  await activeWatchdog.start();
3176
- const pid = activeWatchdog.serverProcess.pid;
3177
- if (pid) {
3178
- await writePidFile({
3179
- pid,
3180
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3181
- world: serverConfig.world,
3182
- port: serverConfig.port
3183
- });
3719
+ const logPath = activeWatchdog.serverProcess.logFilePath;
3720
+ if (logPath) {
3721
+ console.log(`
3722
+ Server log: ${logPath}`);
3184
3723
  }
3185
- console.log("\nServer started. Press Ctrl+C to stop.\n");
3186
- await new Promise(() => {
3187
- });
3724
+ console.log("\nServer is starting in detached mode.");
3725
+ console.log(
3726
+ "Press Ctrl+C to stop monitoring (server will keep running).\n"
3727
+ );
3728
+ const timeout = 12e4;
3729
+ const startTime = Date.now();
3730
+ while (Date.now() - startTime < timeout) {
3731
+ const state = activeWatchdog.serverProcess.currentState;
3732
+ if (state === "online") {
3733
+ await new Promise((resolve) => setTimeout(resolve, 2e3));
3734
+ break;
3735
+ }
3736
+ if (state === "crashed" || state === "offline") {
3737
+ console.error("\nServer failed to start.");
3738
+ await cleanupAndExit(1);
3739
+ return;
3740
+ }
3741
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
3742
+ }
3743
+ console.log("\nDetaching from server...");
3744
+ await activeWatchdog.serverProcess.detach();
3745
+ activeWatchdog = null;
3746
+ process.exit(0);
3188
3747
  } catch (error2) {
3189
3748
  console.error(`
3190
3749
  Failed to start server: ${error2.message}`);
@@ -3197,14 +3756,26 @@ function getActiveWatchdog() {
3197
3756
  function clearActiveWatchdog() {
3198
3757
  activeWatchdog = null;
3199
3758
  }
3759
+ async function cleanupAndExit(code) {
3760
+ if (activeWatchdog) {
3761
+ try {
3762
+ await activeWatchdog.serverProcess.detach();
3763
+ } catch {
3764
+ }
3765
+ activeWatchdog = null;
3766
+ }
3767
+ process.exit(code);
3768
+ }
3200
3769
  function setupShutdownHandlers() {
3201
3770
  const shutdown = async () => {
3202
- console.log("\n\nShutting down...");
3771
+ console.log("\n\nDetaching from server (it will keep running)...");
3203
3772
  if (activeWatchdog) {
3204
- await activeWatchdog.stop();
3773
+ try {
3774
+ await activeWatchdog.serverProcess.detach();
3775
+ } catch {
3776
+ }
3205
3777
  activeWatchdog = null;
3206
3778
  }
3207
- await removePidFile();
3208
3779
  process.exit(0);
3209
3780
  };
3210
3781
  process.on("SIGINT", shutdown);
@@ -3215,9 +3786,9 @@ function setupShutdownHandlers() {
3215
3786
 
3216
3787
  // src/cli/commands/stop.ts
3217
3788
  async function stopCommand(args) {
3789
+ const timeout = args.timeout ?? 3e4;
3218
3790
  const watchdog2 = getActiveWatchdog();
3219
3791
  if (watchdog2) {
3220
- const timeout = args.timeout ?? 3e4;
3221
3792
  if (args.force) {
3222
3793
  console.log("\nForce stopping server...");
3223
3794
  await watchdog2.kill();
@@ -3237,34 +3808,53 @@ Stopping server (timeout: ${timeout}ms)...`);
3237
3808
  console.log("\nNote: Run 'valheim-dsm start' to start a server.");
3238
3809
  return;
3239
3810
  }
3240
- const { pid, world, port, startedAt } = runningServer;
3811
+ const { pid, world, port, startedAt, detached, logFile } = runningServer;
3241
3812
  console.log(`
3242
3813
  Found running server:`);
3243
3814
  console.log(` PID: ${pid}`);
3244
3815
  console.log(` World: ${world}`);
3245
3816
  console.log(` Port: ${port}`);
3246
3817
  console.log(` Started: ${new Date(startedAt).toLocaleString()}`);
3818
+ console.log(` Mode: ${detached ? "Detached" : "Attached"}`);
3819
+ if (logFile) {
3820
+ console.log(` Log: ${logFile}`);
3821
+ }
3247
3822
  if (!isProcessRunning(pid)) {
3248
3823
  console.log("\nServer process is no longer running. Cleaning up...");
3249
3824
  await removePidFile();
3250
3825
  return;
3251
3826
  }
3827
+ const platform = getPlatform();
3252
3828
  if (args.force) {
3253
3829
  console.log("\nForce killing server...");
3254
- killProcess(pid, true);
3830
+ killProcess(pid, platform !== "windows");
3255
3831
  } else {
3256
3832
  console.log("\nSending stop signal...");
3257
3833
  killProcess(pid, false);
3258
- const timeout = args.timeout ?? 3e4;
3259
3834
  const startTime = Date.now();
3835
+ let dots = 0;
3260
3836
  while (isProcessRunning(pid) && Date.now() - startTime < timeout) {
3261
3837
  await new Promise((resolve) => setTimeout(resolve, 500));
3262
3838
  process.stdout.write(".");
3839
+ dots++;
3263
3840
  }
3264
- console.log();
3841
+ if (dots > 0) console.log();
3265
3842
  if (isProcessRunning(pid)) {
3266
3843
  console.log("Server did not stop gracefully, force killing...");
3267
- killProcess(pid, true);
3844
+ killProcess(pid, platform !== "windows");
3845
+ await new Promise((resolve) => setTimeout(resolve, 2e3));
3846
+ if (isProcessRunning(pid)) {
3847
+ console.error(
3848
+ "Failed to stop server. Process may require manual termination."
3849
+ );
3850
+ console.log(` PID: ${pid}`);
3851
+ if (platform === "windows") {
3852
+ console.log(` Try: taskkill /F /PID ${pid}`);
3853
+ } else {
3854
+ console.log(` Try: kill -9 ${pid}`);
3855
+ }
3856
+ return;
3857
+ }
3268
3858
  }
3269
3859
  }
3270
3860
  await removePidFile();
@@ -3793,7 +4383,7 @@ import { useEffect as useEffect3, useMemo as useMemo2, useRef as useRef2, useSta
3793
4383
  // package.json
3794
4384
  var package_default = {
3795
4385
  name: "valheim-oz-dsm",
3796
- version: "1.5.1",
4386
+ version: "1.6.1",
3797
4387
  description: "Land of OZ - Valheim Dedicated Server Manager",
3798
4388
  type: "module",
3799
4389
  bin: {
@@ -3812,7 +4402,8 @@ var package_default = {
3812
4402
  },
3813
4403
  scripts: {
3814
4404
  dev: "tsx watch main.ts",
3815
- start: "tsx main.ts",
4405
+ start: "npm run build && node dist/main.js",
4406
+ "start:dev": "tsx main.ts",
3816
4407
  build: "tsup",
3817
4408
  test: "vitest run",
3818
4409
  "test:watch": "vitest",
@@ -3825,7 +4416,7 @@ var package_default = {
3825
4416
  prepublishOnly: "npm run typecheck && npm run lint && npm test && npm run build"
3826
4417
  },
3827
4418
  dependencies: {
3828
- "@caleb-collar/steamcmd": "^1.1.0",
4419
+ "@caleb-collar/steamcmd": "^1.1.1",
3829
4420
  conf: "^13.0.1",
3830
4421
  "fullscreen-ink": "^0.1.0",
3831
4422
  ink: "^6.6.0",
@@ -43037,46 +43628,81 @@ var Spinner = (props) => {
43037
43628
  };
43038
43629
 
43039
43630
  // src/tui/hooks/useServer.ts
43040
- import { useCallback as useCallback3, useEffect as useEffect6 } from "react";
43631
+ import { useCallback as useCallback3, useEffect as useEffect6, useRef as useRef3 } from "react";
43041
43632
 
43042
43633
  // src/tui/serverManager.ts
43043
43634
  var watchdog = null;
43044
43635
  var updating = false;
43636
+ var isAttached = false;
43045
43637
  function getWatchdog() {
43046
43638
  return watchdog;
43047
43639
  }
43048
43640
  function hasActiveServer() {
43049
43641
  return watchdog !== null;
43050
43642
  }
43643
+ function isAttachedToServer() {
43644
+ return isAttached;
43645
+ }
43051
43646
  function isUpdating() {
43052
43647
  return updating;
43053
43648
  }
43054
43649
  function setUpdating(value) {
43055
43650
  updating = value;
43056
43651
  }
43652
+ async function checkRunningServer() {
43653
+ return getRunningServer();
43654
+ }
43655
+ async function attachToServer(pidData, events) {
43656
+ if (watchdog) {
43657
+ throw new Error("Already managing a server - stop it first");
43658
+ }
43659
+ const config = {
43660
+ name: pidData.serverName ?? "Valheim Server",
43661
+ port: pidData.port,
43662
+ world: pidData.world,
43663
+ password: "",
43664
+ // Not needed for attach
43665
+ public: false,
43666
+ crossplay: false,
43667
+ detached: true
43668
+ };
43669
+ watchdog = new Watchdog(config, { enabled: false }, events);
43670
+ await watchdog.serverProcess.attach(pidData);
43671
+ isAttached = true;
43672
+ return watchdog;
43673
+ }
43057
43674
  async function startServer(config, watchdogConfig, events) {
43058
43675
  if (watchdog) {
43059
43676
  throw new Error("Server is already running");
43060
43677
  }
43061
- watchdog = new Watchdog(config, watchdogConfig, events);
43062
- await watchdog.start();
43063
- const pid = watchdog.serverProcess.pid;
43064
- if (pid) {
43065
- await writePidFile({
43066
- pid,
43067
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
43068
- world: config.world,
43069
- port: config.port
43070
- });
43678
+ const running = await getRunningServer();
43679
+ if (running) {
43680
+ throw new Error(
43681
+ `A server is already running (PID: ${running.pid}, World: ${running.world}). Stop it first or attach to it.`
43682
+ );
43071
43683
  }
43684
+ const detachedConfig = {
43685
+ ...config,
43686
+ detached: true
43687
+ };
43688
+ watchdog = new Watchdog(detachedConfig, watchdogConfig, events);
43689
+ await watchdog.start();
43690
+ isAttached = false;
43072
43691
  return watchdog;
43073
43692
  }
43074
- async function stopServer() {
43693
+ async function stopServer(keepRunning = false) {
43075
43694
  if (!watchdog) {
43076
43695
  return;
43077
43696
  }
43697
+ if (keepRunning && isAttached) {
43698
+ await watchdog.serverProcess.detach();
43699
+ watchdog = null;
43700
+ isAttached = false;
43701
+ return;
43702
+ }
43078
43703
  await watchdog.stop();
43079
43704
  watchdog = null;
43705
+ isAttached = false;
43080
43706
  await removePidFile();
43081
43707
  }
43082
43708
  async function killServer() {
@@ -43085,21 +43711,46 @@ async function killServer() {
43085
43711
  }
43086
43712
  await watchdog.kill();
43087
43713
  watchdog = null;
43714
+ isAttached = false;
43088
43715
  await removePidFile();
43089
43716
  }
43717
+ async function detachFromServer() {
43718
+ if (!watchdog) {
43719
+ return;
43720
+ }
43721
+ if (!isAttached && !watchdog.serverProcess.isDetached) {
43722
+ throw new Error("Cannot detach from a non-detached server");
43723
+ }
43724
+ await watchdog.serverProcess.detach();
43725
+ watchdog = null;
43726
+ isAttached = false;
43727
+ }
43728
+ function getLogFilePath() {
43729
+ return watchdog?.serverProcess.logFilePath ?? null;
43730
+ }
43090
43731
  async function cleanupOnExit() {
43091
- if (watchdog) {
43732
+ if (!watchdog) {
43733
+ return;
43734
+ }
43735
+ if (isAttached || watchdog.serverProcess.isDetached) {
43092
43736
  try {
43093
- await watchdog.stop();
43737
+ await watchdog.serverProcess.detach();
43094
43738
  } catch {
43095
- try {
43096
- await watchdog.kill();
43097
- } catch {
43098
- }
43099
43739
  }
43100
43740
  watchdog = null;
43101
- await removePidFile();
43741
+ isAttached = false;
43742
+ return;
43102
43743
  }
43744
+ try {
43745
+ await watchdog.stop();
43746
+ } catch {
43747
+ try {
43748
+ await watchdog.kill();
43749
+ } catch {
43750
+ }
43751
+ }
43752
+ watchdog = null;
43753
+ await removePidFile();
43103
43754
  }
43104
43755
 
43105
43756
  // src/tui/hooks/useServer.ts
@@ -43135,6 +43786,7 @@ function useServer() {
43135
43786
  const config = useStore((s) => s.config);
43136
43787
  const rcon = useStore((s) => s.rcon);
43137
43788
  const actions = useStore((s) => s.actions);
43789
+ const hasCheckedForRunning = useRef3(false);
43138
43790
  const createWatchdogEvents = useCallback3(
43139
43791
  () => ({
43140
43792
  onStateChange: (state) => {
@@ -43206,6 +43858,38 @@ function useServer() {
43206
43858
  }),
43207
43859
  [actions]
43208
43860
  );
43861
+ const checkAndAttach = useCallback3(async () => {
43862
+ if (hasActiveServer()) {
43863
+ return;
43864
+ }
43865
+ const running = await checkRunningServer();
43866
+ if (!running) {
43867
+ return;
43868
+ }
43869
+ actions.addLog(
43870
+ "info",
43871
+ `Found running server (PID: ${running.pid}, World: ${running.world})`
43872
+ );
43873
+ actions.addLog("info", "Attaching to running server...");
43874
+ try {
43875
+ const events = createWatchdogEvents();
43876
+ await attachToServer(running, events);
43877
+ actions.setServerPid(running.pid);
43878
+ actions.setWorld(running.world);
43879
+ actions.setServerStatus("online");
43880
+ actions.setStartupPhase("ready");
43881
+ actions.addLog("info", "Successfully attached to running server");
43882
+ } catch (error2) {
43883
+ actions.addLog("error", `Failed to attach to running server: ${error2}`);
43884
+ }
43885
+ }, [actions, createWatchdogEvents]);
43886
+ useEffect6(() => {
43887
+ if (hasCheckedForRunning.current) return;
43888
+ hasCheckedForRunning.current = true;
43889
+ checkAndAttach().catch((error2) => {
43890
+ actions.addLog("error", `Error checking for running server: ${error2}`);
43891
+ });
43892
+ }, [checkAndAttach, actions]);
43209
43893
  const start = useCallback3(async () => {
43210
43894
  if (status !== "offline") {
43211
43895
  actions.addLog("warn", "Server is not offline, cannot start");
@@ -43326,6 +44010,18 @@ function useServer() {
43326
44010
  actions.addLog("error", `Failed to send save command: ${error2}`);
43327
44011
  }
43328
44012
  }, [rcon, actions]);
44013
+ const detach = useCallback3(async () => {
44014
+ if (!hasActiveServer()) {
44015
+ actions.addLog("warn", "No server to detach from");
44016
+ return;
44017
+ }
44018
+ try {
44019
+ await detachFromServer();
44020
+ actions.addLog("info", "Detached from server - it will continue running");
44021
+ } catch (error2) {
44022
+ actions.addLog("error", `Failed to detach: ${error2}`);
44023
+ }
44024
+ }, [actions]);
43329
44025
  useEffect6(() => {
43330
44026
  if (status !== "online") return;
43331
44027
  const interval = setInterval(() => {
@@ -43341,10 +44037,14 @@ function useServer() {
43341
44037
  restart,
43342
44038
  update,
43343
44039
  forceSave,
44040
+ detach,
44041
+ checkAndAttach,
43344
44042
  isOnline: status === "online",
43345
44043
  isOffline: status === "offline",
43346
44044
  isTransitioning: status === "starting" || status === "stopping",
43347
44045
  isUpdating: isUpdating(),
44046
+ isAttached: isAttachedToServer(),
44047
+ logFilePath: getLogFilePath(),
43348
44048
  watchdog: getWatchdog()
43349
44049
  };
43350
44050
  }
@@ -46006,7 +46706,7 @@ function launchTui() {
46006
46706
  }
46007
46707
 
46008
46708
  // src/mod.ts
46009
- var VERSION2 = "1.5.1";
46709
+ var VERSION2 = "1.6.1";
46010
46710
  var APP_NAME = "Land of OZ - Valheim DSM";
46011
46711
 
46012
46712
  // main.ts