valheim-oz-dsm 1.5.4 → 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
@@ -2644,6 +2644,25 @@ import * as fs5 from "fs/promises";
2644
2644
  import { dirname, join } from "path";
2645
2645
 
2646
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
+ }
2647
2666
  function parseEvent(line) {
2648
2667
  if (line.includes("Got character ZDOID from")) {
2649
2668
  const match = line.match(/Got character ZDOID from (\S+)/);
@@ -2690,9 +2709,179 @@ function parseEvent(line) {
2690
2709
  return null;
2691
2710
  }
2692
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
+
2693
2874
  // src/server/pidfile.ts
2694
2875
  import fs6 from "fs/promises";
2695
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
+ }
2696
2885
  function getPidFilePath() {
2697
2886
  return path5.join(getConfigDir(), "oz-valheim", "server.pid");
2698
2887
  }
@@ -2746,9 +2935,28 @@ async function getRunningServer() {
2746
2935
  }
2747
2936
  return data;
2748
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
+ }
2749
2956
 
2750
2957
  // src/server/process.ts
2751
2958
  import { spawn } from "child_process";
2959
+ import { createWriteStream } from "fs";
2752
2960
  var defaultEvents = {
2753
2961
  onStateChange: () => {
2754
2962
  },
@@ -2767,6 +2975,12 @@ var ValheimProcess = class {
2767
2975
  events;
2768
2976
  config;
2769
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;
2770
2984
  /**
2771
2985
  * Creates a new Valheim process wrapper
2772
2986
  * @param config Server launch configuration
@@ -2778,7 +2992,7 @@ var ValheimProcess = class {
2778
2992
  }
2779
2993
  /** Gets the process ID if running, null otherwise */
2780
2994
  get pid() {
2781
- return this.process?.pid ?? null;
2995
+ return this.process?.pid ?? this._detachedPid ?? null;
2782
2996
  }
2783
2997
  /** Gets the current process state */
2784
2998
  get currentState() {
@@ -2788,6 +3002,14 @@ var ValheimProcess = class {
2788
3002
  get uptime() {
2789
3003
  return this.startTime;
2790
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
+ }
2791
3013
  /**
2792
3014
  * Updates the process state and notifies listeners
2793
3015
  * @param newState New process state
@@ -2806,21 +3028,16 @@ var ValheimProcess = class {
2806
3028
  }
2807
3029
  this.setState("starting");
2808
3030
  this.startTime = /* @__PURE__ */ new Date();
3031
+ this._isDetached = this.config.detached ?? false;
2809
3032
  const execPath = getValheimExecutablePath();
2810
3033
  const args = this.buildArgs();
2811
3034
  const env = this.getEnvironment();
2812
3035
  try {
2813
- this.process = spawn(execPath, args, {
2814
- stdio: ["ignore", "pipe", "pipe"],
2815
- env
2816
- });
2817
- this.streamOutput();
2818
- this.process.on("error", (error2) => {
2819
- this.setState("crashed");
2820
- this.startTime = null;
2821
- this.events.onError(error2);
2822
- });
2823
- await Promise.resolve();
3036
+ if (this._isDetached) {
3037
+ await this.startDetached(execPath, args, env);
3038
+ } else {
3039
+ await this.startAttached(execPath, args, env);
3040
+ }
2824
3041
  } catch (error2) {
2825
3042
  this.setState("crashed");
2826
3043
  this.startTime = null;
@@ -2828,6 +3045,169 @@ var ValheimProcess = class {
2828
3045
  throw error2;
2829
3046
  }
2830
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
+ }
2831
3211
  /**
2832
3212
  * Gracefully stops the server with optional timeout
2833
3213
  * @param timeout Maximum time to wait for graceful shutdown (ms)
@@ -2837,6 +3217,31 @@ var ValheimProcess = class {
2837
3217
  return;
2838
3218
  }
2839
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
+ }
2840
3245
  if (this.process) {
2841
3246
  try {
2842
3247
  this.process.kill("SIGTERM");
@@ -2864,6 +3269,18 @@ var ValheimProcess = class {
2864
3269
  * Immediately kills the server process
2865
3270
  */
2866
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
+ }
2867
3284
  if (this.process) {
2868
3285
  try {
2869
3286
  this.process.kill("SIGKILL");
@@ -2875,6 +3292,36 @@ var ValheimProcess = class {
2875
3292
  this.setState("offline");
2876
3293
  await Promise.resolve();
2877
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
+ }
2878
3325
  /**
2879
3326
  * Builds command line arguments for Valheim server
2880
3327
  * @returns Array of command line arguments
@@ -3190,6 +3637,18 @@ async function startCommand(args, config) {
3190
3637
  console.log("Run 'valheim-dsm install' first to install the server.");
3191
3638
  process.exit(1);
3192
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();
3193
3652
  const serverConfig = {
3194
3653
  name: args.name ?? config.server.name,
3195
3654
  port: args.port ?? config.server.port,
@@ -3199,7 +3658,9 @@ async function startCommand(args, config) {
3199
3658
  crossplay: args.crossplay ?? config.server.crossplay,
3200
3659
  savedir: args.savedir ?? config.server.savedir,
3201
3660
  saveinterval: config.server.saveinterval,
3202
- backups: config.server.backups
3661
+ backups: config.server.backups,
3662
+ // Always use detached mode for stability
3663
+ detached: true
3203
3664
  };
3204
3665
  console.log(`
3205
3666
  Starting ${serverConfig.name}...`);
@@ -3207,6 +3668,7 @@ Starting ${serverConfig.name}...`);
3207
3668
  console.log(` Port: ${serverConfig.port}`);
3208
3669
  console.log(` Public: ${serverConfig.public}`);
3209
3670
  console.log(` Crossplay: ${serverConfig.crossplay}`);
3671
+ console.log(` Mode: Detached (server continues after terminal exits)`);
3210
3672
  console.log("");
3211
3673
  activeWatchdog = new Watchdog(
3212
3674
  serverConfig,
@@ -3220,6 +3682,12 @@ Starting ${serverConfig.name}...`);
3220
3682
  {
3221
3683
  onStateChange: (state) => {
3222
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
+ }
3223
3691
  },
3224
3692
  onLog: (line) => {
3225
3693
  console.log(`[Server] ${line}`);
@@ -3248,18 +3716,34 @@ Starting ${serverConfig.name}...`);
3248
3716
  setupShutdownHandlers();
3249
3717
  try {
3250
3718
  await activeWatchdog.start();
3251
- const pid = activeWatchdog.serverProcess.pid;
3252
- if (pid) {
3253
- await writePidFile({
3254
- pid,
3255
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3256
- world: serverConfig.world,
3257
- port: serverConfig.port
3258
- });
3719
+ const logPath = activeWatchdog.serverProcess.logFilePath;
3720
+ if (logPath) {
3721
+ console.log(`
3722
+ Server log: ${logPath}`);
3259
3723
  }
3260
- console.log("\nServer started. Press Ctrl+C to stop.\n");
3261
- await new Promise(() => {
3262
- });
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);
3263
3747
  } catch (error2) {
3264
3748
  console.error(`
3265
3749
  Failed to start server: ${error2.message}`);
@@ -3272,14 +3756,26 @@ function getActiveWatchdog() {
3272
3756
  function clearActiveWatchdog() {
3273
3757
  activeWatchdog = null;
3274
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
+ }
3275
3769
  function setupShutdownHandlers() {
3276
3770
  const shutdown = async () => {
3277
- console.log("\n\nShutting down...");
3771
+ console.log("\n\nDetaching from server (it will keep running)...");
3278
3772
  if (activeWatchdog) {
3279
- await activeWatchdog.stop();
3773
+ try {
3774
+ await activeWatchdog.serverProcess.detach();
3775
+ } catch {
3776
+ }
3280
3777
  activeWatchdog = null;
3281
3778
  }
3282
- await removePidFile();
3283
3779
  process.exit(0);
3284
3780
  };
3285
3781
  process.on("SIGINT", shutdown);
@@ -3290,9 +3786,9 @@ function setupShutdownHandlers() {
3290
3786
 
3291
3787
  // src/cli/commands/stop.ts
3292
3788
  async function stopCommand(args) {
3789
+ const timeout = args.timeout ?? 3e4;
3293
3790
  const watchdog2 = getActiveWatchdog();
3294
3791
  if (watchdog2) {
3295
- const timeout = args.timeout ?? 3e4;
3296
3792
  if (args.force) {
3297
3793
  console.log("\nForce stopping server...");
3298
3794
  await watchdog2.kill();
@@ -3312,34 +3808,53 @@ Stopping server (timeout: ${timeout}ms)...`);
3312
3808
  console.log("\nNote: Run 'valheim-dsm start' to start a server.");
3313
3809
  return;
3314
3810
  }
3315
- const { pid, world, port, startedAt } = runningServer;
3811
+ const { pid, world, port, startedAt, detached, logFile } = runningServer;
3316
3812
  console.log(`
3317
3813
  Found running server:`);
3318
3814
  console.log(` PID: ${pid}`);
3319
3815
  console.log(` World: ${world}`);
3320
3816
  console.log(` Port: ${port}`);
3321
3817
  console.log(` Started: ${new Date(startedAt).toLocaleString()}`);
3818
+ console.log(` Mode: ${detached ? "Detached" : "Attached"}`);
3819
+ if (logFile) {
3820
+ console.log(` Log: ${logFile}`);
3821
+ }
3322
3822
  if (!isProcessRunning(pid)) {
3323
3823
  console.log("\nServer process is no longer running. Cleaning up...");
3324
3824
  await removePidFile();
3325
3825
  return;
3326
3826
  }
3827
+ const platform = getPlatform();
3327
3828
  if (args.force) {
3328
3829
  console.log("\nForce killing server...");
3329
- killProcess(pid, true);
3830
+ killProcess(pid, platform !== "windows");
3330
3831
  } else {
3331
3832
  console.log("\nSending stop signal...");
3332
3833
  killProcess(pid, false);
3333
- const timeout = args.timeout ?? 3e4;
3334
3834
  const startTime = Date.now();
3835
+ let dots = 0;
3335
3836
  while (isProcessRunning(pid) && Date.now() - startTime < timeout) {
3336
3837
  await new Promise((resolve) => setTimeout(resolve, 500));
3337
3838
  process.stdout.write(".");
3839
+ dots++;
3338
3840
  }
3339
- console.log();
3841
+ if (dots > 0) console.log();
3340
3842
  if (isProcessRunning(pid)) {
3341
3843
  console.log("Server did not stop gracefully, force killing...");
3342
- 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
+ }
3343
3858
  }
3344
3859
  }
3345
3860
  await removePidFile();
@@ -3868,7 +4383,7 @@ import { useEffect as useEffect3, useMemo as useMemo2, useRef as useRef2, useSta
3868
4383
  // package.json
3869
4384
  var package_default = {
3870
4385
  name: "valheim-oz-dsm",
3871
- version: "1.5.4",
4386
+ version: "1.6.1",
3872
4387
  description: "Land of OZ - Valheim Dedicated Server Manager",
3873
4388
  type: "module",
3874
4389
  bin: {
@@ -3877,7 +4392,6 @@ var package_default = {
3877
4392
  main: "./dist/main.js",
3878
4393
  files: [
3879
4394
  "dist",
3880
- "patches",
3881
4395
  "README.md",
3882
4396
  "LICENSE",
3883
4397
  "CHANGELOG.md"
@@ -3898,12 +4412,11 @@ var package_default = {
3898
4412
  "lint:fix": "biome check --write .",
3899
4413
  format: "biome format --write .",
3900
4414
  typecheck: "tsc --noEmit",
3901
- postinstall: "patch-package",
3902
4415
  prepare: "tsx scripts/install-hooks.ts",
3903
4416
  prepublishOnly: "npm run typecheck && npm run lint && npm test && npm run build"
3904
4417
  },
3905
4418
  dependencies: {
3906
- "@caleb-collar/steamcmd": "^1.1.0",
4419
+ "@caleb-collar/steamcmd": "^1.1.1",
3907
4420
  conf: "^13.0.1",
3908
4421
  "fullscreen-ink": "^0.1.0",
3909
4422
  ink: "^6.6.0",
@@ -3916,7 +4429,6 @@ var package_default = {
3916
4429
  "@types/node": "^22.13.1",
3917
4430
  "@types/react": "^19.2.10",
3918
4431
  "@vitest/coverage-v8": "^3.2.4",
3919
- "patch-package": "^8.0.1",
3920
4432
  tsup: "^8.3.6",
3921
4433
  tsx: "^4.19.2",
3922
4434
  typescript: "^5.7.3",
@@ -43116,46 +43628,81 @@ var Spinner = (props) => {
43116
43628
  };
43117
43629
 
43118
43630
  // src/tui/hooks/useServer.ts
43119
- import { useCallback as useCallback3, useEffect as useEffect6 } from "react";
43631
+ import { useCallback as useCallback3, useEffect as useEffect6, useRef as useRef3 } from "react";
43120
43632
 
43121
43633
  // src/tui/serverManager.ts
43122
43634
  var watchdog = null;
43123
43635
  var updating = false;
43636
+ var isAttached = false;
43124
43637
  function getWatchdog() {
43125
43638
  return watchdog;
43126
43639
  }
43127
43640
  function hasActiveServer() {
43128
43641
  return watchdog !== null;
43129
43642
  }
43643
+ function isAttachedToServer() {
43644
+ return isAttached;
43645
+ }
43130
43646
  function isUpdating() {
43131
43647
  return updating;
43132
43648
  }
43133
43649
  function setUpdating(value) {
43134
43650
  updating = value;
43135
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
+ }
43136
43674
  async function startServer(config, watchdogConfig, events) {
43137
43675
  if (watchdog) {
43138
43676
  throw new Error("Server is already running");
43139
43677
  }
43140
- watchdog = new Watchdog(config, watchdogConfig, events);
43141
- await watchdog.start();
43142
- const pid = watchdog.serverProcess.pid;
43143
- if (pid) {
43144
- await writePidFile({
43145
- pid,
43146
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
43147
- world: config.world,
43148
- port: config.port
43149
- });
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
+ );
43150
43683
  }
43684
+ const detachedConfig = {
43685
+ ...config,
43686
+ detached: true
43687
+ };
43688
+ watchdog = new Watchdog(detachedConfig, watchdogConfig, events);
43689
+ await watchdog.start();
43690
+ isAttached = false;
43151
43691
  return watchdog;
43152
43692
  }
43153
- async function stopServer() {
43693
+ async function stopServer(keepRunning = false) {
43154
43694
  if (!watchdog) {
43155
43695
  return;
43156
43696
  }
43697
+ if (keepRunning && isAttached) {
43698
+ await watchdog.serverProcess.detach();
43699
+ watchdog = null;
43700
+ isAttached = false;
43701
+ return;
43702
+ }
43157
43703
  await watchdog.stop();
43158
43704
  watchdog = null;
43705
+ isAttached = false;
43159
43706
  await removePidFile();
43160
43707
  }
43161
43708
  async function killServer() {
@@ -43164,21 +43711,46 @@ async function killServer() {
43164
43711
  }
43165
43712
  await watchdog.kill();
43166
43713
  watchdog = null;
43714
+ isAttached = false;
43167
43715
  await removePidFile();
43168
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
+ }
43169
43731
  async function cleanupOnExit() {
43170
- if (watchdog) {
43732
+ if (!watchdog) {
43733
+ return;
43734
+ }
43735
+ if (isAttached || watchdog.serverProcess.isDetached) {
43171
43736
  try {
43172
- await watchdog.stop();
43737
+ await watchdog.serverProcess.detach();
43173
43738
  } catch {
43174
- try {
43175
- await watchdog.kill();
43176
- } catch {
43177
- }
43178
43739
  }
43179
43740
  watchdog = null;
43180
- await removePidFile();
43741
+ isAttached = false;
43742
+ return;
43181
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();
43182
43754
  }
43183
43755
 
43184
43756
  // src/tui/hooks/useServer.ts
@@ -43214,6 +43786,7 @@ function useServer() {
43214
43786
  const config = useStore((s) => s.config);
43215
43787
  const rcon = useStore((s) => s.rcon);
43216
43788
  const actions = useStore((s) => s.actions);
43789
+ const hasCheckedForRunning = useRef3(false);
43217
43790
  const createWatchdogEvents = useCallback3(
43218
43791
  () => ({
43219
43792
  onStateChange: (state) => {
@@ -43285,6 +43858,38 @@ function useServer() {
43285
43858
  }),
43286
43859
  [actions]
43287
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]);
43288
43893
  const start = useCallback3(async () => {
43289
43894
  if (status !== "offline") {
43290
43895
  actions.addLog("warn", "Server is not offline, cannot start");
@@ -43405,6 +44010,18 @@ function useServer() {
43405
44010
  actions.addLog("error", `Failed to send save command: ${error2}`);
43406
44011
  }
43407
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]);
43408
44025
  useEffect6(() => {
43409
44026
  if (status !== "online") return;
43410
44027
  const interval = setInterval(() => {
@@ -43420,10 +44037,14 @@ function useServer() {
43420
44037
  restart,
43421
44038
  update,
43422
44039
  forceSave,
44040
+ detach,
44041
+ checkAndAttach,
43423
44042
  isOnline: status === "online",
43424
44043
  isOffline: status === "offline",
43425
44044
  isTransitioning: status === "starting" || status === "stopping",
43426
44045
  isUpdating: isUpdating(),
44046
+ isAttached: isAttachedToServer(),
44047
+ logFilePath: getLogFilePath(),
43427
44048
  watchdog: getWatchdog()
43428
44049
  };
43429
44050
  }
@@ -46085,7 +46706,7 @@ function launchTui() {
46085
46706
  }
46086
46707
 
46087
46708
  // src/mod.ts
46088
- var VERSION2 = "1.5.4";
46709
+ var VERSION2 = "1.6.1";
46089
46710
  var APP_NAME = "Land of OZ - Valheim DSM";
46090
46711
 
46091
46712
  // main.ts