@spencer-kit/coder-studio 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
Binary file
@@ -0,0 +1,19 @@
1
+ <svg
2
+ xmlns="http://www.w3.org/2000/svg"
3
+ width="1024"
4
+ height="1024"
5
+ viewBox="0 0 1024 1024"
6
+ fill="none"
7
+ >
8
+ <path
9
+ d="M558 160C540 278 480 370 408 446C358 499 301 536 287 568C270 607 332 613 416 614C512 615 566 634 566 684C566 739 516 797 449 867"
10
+ stroke="#8CCFFF"
11
+ stroke-width="108"
12
+ stroke-linecap="round"
13
+ stroke-linejoin="round"
14
+ />
15
+ <path
16
+ d="M512 388C528 470 554 496 636 512C554 528 528 554 512 636C496 554 470 528 388 512C470 496 496 470 512 388Z"
17
+ fill="#FFFFFF"
18
+ />
19
+ </svg>
@@ -5,16 +5,16 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <meta name="description" content="Coder Studio - Agent-First Development Environment" />
7
7
  <title>Coder Studio</title>
8
- <link rel="icon" type="image/svg+xml" href="/vite.svg" />
9
- <script type="module" crossorigin src="/assets/index-A-2YPePM.js"></script>
8
+ <link rel="icon" type="image/x-icon" href="/favicon.ico" />
9
+ <script type="module" crossorigin src="/assets/index-BjrMfcUG.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-S-ySWqyJ.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/monaco-editor-CZixARFH.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/xterm-0bvFymvt.js">
13
13
  <link rel="stylesheet" crossorigin href="/assets/monaco-editor-Br_kD0ds.css">
14
14
  <link rel="stylesheet" crossorigin href="/assets/xterm-BrP-ENHg.css">
15
- <link rel="stylesheet" crossorigin href="/assets/index-BLMivSS1.css">
15
+ <link rel="stylesheet" crossorigin href="/assets/index-mL_Aq31j.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
19
19
  </body>
20
- </html>
20
+ </html>
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@spencer-kit/coder-studio",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
- "description": "Coder Studio CLI - The only published package",
5
+ "description": "Deploy once, code everywhere. Browser-based AI coding workspace for Claude Code and Codex.",
6
6
  "main": "./dist/esm/index.mjs",
7
7
  "bin": {
8
8
  "coder-studio": "./dist/bin.js"
@@ -0,0 +1,50 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const { spawnMock } = vi.hoisted(() => ({
5
+ spawnMock: vi.fn(),
6
+ }));
7
+
8
+ vi.mock("node:child_process", () => ({
9
+ spawn: spawnMock,
10
+ }));
11
+
12
+ import { openBrowser } from "./browser.js";
13
+
14
+ const originalPlatform = process.platform;
15
+
16
+ describe("openBrowser windows child-process options", () => {
17
+ afterEach(() => {
18
+ Object.defineProperty(process, "platform", {
19
+ value: originalPlatform,
20
+ configurable: true,
21
+ });
22
+ vi.clearAllMocks();
23
+ });
24
+
25
+ it("uses the Windows open command and passes windowsHide to spawn", async () => {
26
+ Object.defineProperty(process, "platform", {
27
+ value: "win32",
28
+ configurable: true,
29
+ });
30
+
31
+ spawnMock.mockImplementation(() => {
32
+ const child = new EventEmitter() as EventEmitter & { unref: ReturnType<typeof vi.fn> };
33
+ child.unref = vi.fn();
34
+
35
+ queueMicrotask(() => {
36
+ child.emit("spawn");
37
+ });
38
+
39
+ return child;
40
+ });
41
+
42
+ await expect(openBrowser("https://example.com")).resolves.toBeUndefined();
43
+
44
+ expect(spawnMock).toHaveBeenCalledWith(
45
+ "cmd",
46
+ ["/c", "start", "", "https://example.com"],
47
+ expect.objectContaining({ windowsHide: true })
48
+ );
49
+ });
50
+ });
package/src/browser.ts CHANGED
@@ -18,6 +18,7 @@ export async function openBrowser(url: string): Promise<void> {
18
18
  const child = spawn(command, args, {
19
19
  detached: true,
20
20
  stdio: "ignore",
21
+ windowsHide: true,
21
22
  });
22
23
 
23
24
  child.once("error", reject);
package/src/cli.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "fs";
1
+ import { existsSync } from "fs";
2
2
  import { dirname, join } from "path";
3
3
  import { fileURLToPath } from "url";
4
4
  import { clearAuthBlockByIp, listAuthBlocks } from "./auth-control.js";
@@ -6,6 +6,7 @@ import { openBrowser } from "./browser.js";
6
6
  import { type CliConfig, readCliConfig, writeCliConfig } from "./config-store.js";
7
7
  import { readLogExcerpt } from "./log-excerpt.js";
8
8
  import { assertSupportedNodeVersion } from "./node-version.js";
9
+ import { getCliVersion } from "./package-manifest.js";
9
10
  import { parseArgs } from "./parse-args.js";
10
11
  import { startManagedServer } from "./pm2-control.js";
11
12
  import { confirmYesNo, isInteractiveSession } from "./prompts.js";
@@ -133,16 +134,7 @@ EXAMPLES:
133
134
  }
134
135
 
135
136
  function showVersion(): void {
136
- const manifestPath = [
137
- new URL("../package.json", import.meta.url),
138
- new URL("../../package.json", import.meta.url),
139
- ].find((candidate) => existsSync(candidate));
140
- if (!manifestPath) {
141
- throw new Error("Unable to locate CLI package.json");
142
- }
143
- const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { version?: string };
144
- const version = manifest.version ?? "0.0.0";
145
- console.log(`@spencer-kit/coder-studio v${version}`);
137
+ console.log(`@spencer-kit/coder-studio v${getCliVersion(import.meta.url)}`);
146
138
  }
147
139
 
148
140
  function formatAuthBlocks(blocks: Awaited<ReturnType<typeof listAuthBlocks>>): string {
@@ -1,5 +1,7 @@
1
1
  import { readFileSync } from "fs";
2
+ import { fileURLToPath } from "url";
2
3
  import { describe, expect, it } from "vitest";
4
+ import { getCliVersion, resolveCliPackageManifestUrl } from "./package-manifest.js";
3
5
 
4
6
  interface PackageManifest {
5
7
  dependencies?: Record<string, string>;
@@ -12,6 +14,18 @@ function readPackageManifest(relativePath: string): PackageManifest {
12
14
  }
13
15
 
14
16
  describe("cli package manifest", () => {
17
+ it("resolves the CLI package manifest instead of the workspace root manifest", () => {
18
+ expect(fileURLToPath(resolveCliPackageManifestUrl(import.meta.url))).toBe(
19
+ fileURLToPath(new URL("../package.json", import.meta.url))
20
+ );
21
+ });
22
+
23
+ it("reads the published CLI version from the CLI package manifest", () => {
24
+ const cliPackage = readPackageManifest("../package.json") as { version?: string };
25
+
26
+ expect(getCliVersion(import.meta.url)).toBe(cliPackage.version);
27
+ });
28
+
15
29
  it("declares every external server runtime dependency", () => {
16
30
  const cliPackage = readPackageManifest("../package.json");
17
31
  const serverPackage = readPackageManifest("../../server/package.json");
@@ -0,0 +1,28 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+
3
+ interface CliPackageManifest {
4
+ version?: string;
5
+ }
6
+
7
+ export function resolveCliPackageManifestUrl(importMetaUrl: string): URL {
8
+ const manifestUrl = [
9
+ new URL("../package.json", importMetaUrl),
10
+ new URL("../../package.json", importMetaUrl),
11
+ ].find((candidate) => existsSync(candidate));
12
+
13
+ if (!manifestUrl) {
14
+ throw new Error("Unable to locate CLI package.json");
15
+ }
16
+
17
+ return manifestUrl;
18
+ }
19
+
20
+ export function getCliPackageManifest(importMetaUrl: string): CliPackageManifest {
21
+ return JSON.parse(
22
+ readFileSync(resolveCliPackageManifestUrl(importMetaUrl), "utf-8")
23
+ ) as CliPackageManifest;
24
+ }
25
+
26
+ export function getCliVersion(importMetaUrl: string): string {
27
+ return getCliPackageManifest(importMetaUrl).version ?? "0.0.0";
28
+ }
@@ -33,15 +33,22 @@ import {
33
33
  describe("pm2-control", () => {
34
34
  const originalHome = process.env.HOME;
35
35
  const originalUserProfile = process.env.USERPROFILE;
36
+ const originalRuntimeDir = process.env.CODER_STUDIO_RUNTIME_DIR;
37
+ const originalRuntimeJsonPath = process.env.CODER_STUDIO_RUNTIME_JSON_PATH;
36
38
  let testHomeDir: string;
37
39
 
38
40
  beforeEach(() => {
39
41
  testHomeDir = mkdtempSync(join(tmpdir(), "cs-pm2-control-home-"));
40
42
  process.env.HOME = testHomeDir;
41
43
  process.env.USERPROFILE = testHomeDir;
44
+ process.env.CODER_STUDIO_RUNTIME_DIR = join(testHomeDir, ".coder-studio");
45
+ delete process.env.CODER_STUDIO_RUNTIME_JSON_PATH;
42
46
 
43
47
  connect.mockImplementation((callback: (error: Error | null) => void) => callback(null));
44
- disconnect.mockImplementation(() => undefined);
48
+ disconnect.mockImplementation((callback?: (error: Error | null) => void) => {
49
+ callback?.(null);
50
+ return undefined;
51
+ });
45
52
  start.mockImplementation(
46
53
  (_config: unknown, callback: (error: Error | null, apps: unknown[]) => void) => {
47
54
  writeRuntimeConfig({
@@ -80,6 +87,18 @@ describe("pm2-control", () => {
80
87
  process.env.USERPROFILE = originalUserProfile;
81
88
  }
82
89
 
90
+ if (originalRuntimeDir === undefined) {
91
+ delete process.env.CODER_STUDIO_RUNTIME_DIR;
92
+ } else {
93
+ process.env.CODER_STUDIO_RUNTIME_DIR = originalRuntimeDir;
94
+ }
95
+
96
+ if (originalRuntimeJsonPath === undefined) {
97
+ delete process.env.CODER_STUDIO_RUNTIME_JSON_PATH;
98
+ } else {
99
+ process.env.CODER_STUDIO_RUNTIME_JSON_PATH = originalRuntimeJsonPath;
100
+ }
101
+
83
102
  if (existsSync(testHomeDir)) {
84
103
  rmSync(testHomeDir, { recursive: true, force: true });
85
104
  }
@@ -157,6 +176,67 @@ describe("pm2-control", () => {
157
176
  ).resolves.toBe("waiting");
158
177
 
159
178
  expect(start).not.toHaveBeenCalled();
179
+ await pendingStart;
180
+ });
181
+
182
+ it("reuses one pm2 session while polling deletion during startup", async () => {
183
+ describeProcess
184
+ .mockImplementationOnce(
185
+ (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
186
+ callback(null, [{ pid: 111, pm2_env: { status: "online", restart_time: 0 } }])
187
+ )
188
+ .mockImplementationOnce(
189
+ (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
190
+ callback(null, [{ pid: 111, pm2_env: { status: "stopping", restart_time: 0 } }])
191
+ )
192
+ .mockImplementationOnce(
193
+ (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
194
+ callback(null, [])
195
+ );
196
+
197
+ await startManagedServer({
198
+ script: "/cli/dist/esm/server-runner.js",
199
+ cwd: "/repo",
200
+ waitMs: 10,
201
+ });
202
+
203
+ expect(connect).toHaveBeenCalledTimes(1);
204
+ expect(disconnect).toHaveBeenCalledTimes(1);
205
+ });
206
+
207
+ it("keeps waiting during startup when delete reports missing but the old app still lingers", async () => {
208
+ describeProcess
209
+ .mockImplementationOnce(
210
+ (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
211
+ callback(null, [{ pid: 111, pm2_env: { status: "online", restart_time: 0 } }])
212
+ )
213
+ .mockImplementationOnce(
214
+ (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
215
+ callback(null, [{ pid: 111, pm2_env: { status: "stopping", restart_time: 0 } }])
216
+ )
217
+ .mockImplementationOnce(
218
+ (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
219
+ callback(null, [])
220
+ );
221
+ deleteProcess.mockImplementationOnce((_name: string, callback: (error: Error | null) => void) =>
222
+ callback(new Error("process or namespace not found"))
223
+ );
224
+
225
+ const pendingStart = startManagedServer({
226
+ script: "/cli/dist/esm/server-runner.js",
227
+ cwd: "/repo",
228
+ waitMs: 10,
229
+ });
230
+
231
+ await expect(
232
+ Promise.race([
233
+ pendingStart.then(() => "started"),
234
+ new Promise((resolve) => setTimeout(() => resolve("waiting"), 20)),
235
+ ])
236
+ ).resolves.toBe("waiting");
237
+
238
+ expect(start).not.toHaveBeenCalled();
239
+ await pendingStart;
160
240
  });
161
241
 
162
242
  it("ignores delete-time missing errors when requested", async () => {
@@ -169,6 +249,8 @@ describe("pm2-control", () => {
169
249
  );
170
250
 
171
251
  await expect(deleteManagedServer({ ignoreMissing: true })).resolves.toBe(false);
252
+ expect(connect).toHaveBeenCalledTimes(1);
253
+ expect(disconnect).toHaveBeenCalledTimes(1);
172
254
  });
173
255
 
174
256
  it("fails background startup when runtime readiness times out", async () => {
@@ -183,6 +265,10 @@ describe("pm2-control", () => {
183
265
  callback(null, [])
184
266
  )
185
267
  .mockImplementationOnce(
268
+ (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
269
+ callback(null, [])
270
+ )
271
+ .mockImplementation(
186
272
  (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
187
273
  callback(null, [{ pid: 424242, pm2_env: { status: "online", restart_time: 0 } }])
188
274
  );
@@ -243,6 +329,8 @@ describe("pm2-control", () => {
243
329
  pm2Pid: null,
244
330
  restartCount: 0,
245
331
  });
332
+ expect(connect).toHaveBeenCalledTimes(1);
333
+ expect(disconnect).toHaveBeenCalledTimes(1);
246
334
  });
247
335
 
248
336
  it("maps an online PM2 app to running status", async () => {
@@ -8,6 +8,8 @@ export const MANAGED_SERVER_NAME = "coder-studio-server";
8
8
  const PM2_RESTART_DELAY_MS = 2000;
9
9
  const PM2_MIN_UPTIME = "5s";
10
10
  const PM2_MAX_RESTARTS = 10;
11
+ const PM2_DELETE_WAIT_MS = 5000;
12
+ const PM2_DISCONNECT_WAIT_MS = 1000;
11
13
  const STARTUP_POLL_INTERVAL_MS = 100;
12
14
  const STARTUP_FAILURE_GUIDANCE =
13
15
  "Run `coder-studio logs` for details or `coder-studio serve --foreground` for interactive debugging.";
@@ -61,7 +63,7 @@ const isPm2BrokenStateError = (error: unknown): boolean => {
61
63
 
62
64
  type Pm2Module = {
63
65
  connect: (cb: (err: Error | null) => void) => void;
64
- disconnect: () => void;
66
+ disconnect: (cb?: (err: Error | null, data?: unknown) => void) => void;
65
67
  describe: (name: string, cb: (err: Error | null, result: unknown[]) => void) => void;
66
68
  delete: (name: string, cb: (err: Error | null) => void) => void;
67
69
  start: (opts: unknown, cb: (err: Error | null) => void) => void;
@@ -110,36 +112,51 @@ const sleep = async (ms: number): Promise<void> =>
110
112
 
111
113
  const disconnectPm2 = async (): Promise<void> => {
112
114
  const pm2 = await loadPm2();
113
- pm2.disconnect();
115
+ await new Promise<void>((resolve) => {
116
+ let settled = false;
117
+ const finish = () => {
118
+ if (settled) {
119
+ return;
120
+ }
121
+
122
+ settled = true;
123
+ resolve();
124
+ };
125
+
126
+ const timer = setTimeout(finish, PM2_DISCONNECT_WAIT_MS);
127
+ try {
128
+ pm2.disconnect(() => {
129
+ clearTimeout(timer);
130
+ finish();
131
+ });
132
+ } catch {
133
+ clearTimeout(timer);
134
+ finish();
135
+ }
136
+ });
114
137
  };
115
138
 
116
- const describeManagedServer = async (): Promise<Pm2ProcessDescription[]> => {
117
- const pm2 = await loadPm2();
118
- return new Promise((resolve, reject) => {
139
+ const describeManagedServer = async (pm2: Pm2Module): Promise<Pm2ProcessDescription[]> =>
140
+ new Promise((resolve, reject) => {
119
141
  pm2.describe(MANAGED_SERVER_NAME, (error, result) => {
120
142
  if (error) {
121
143
  reject(error);
122
144
  return;
123
145
  }
124
-
125
146
  resolve((result ?? []) as Pm2ProcessDescription[]);
126
147
  });
127
148
  });
128
- };
129
149
 
130
- const removeManagedServer = async (): Promise<void> => {
131
- const pm2 = await loadPm2();
132
- return new Promise((resolve, reject) => {
150
+ const removeManagedServer = async (pm2: Pm2Module): Promise<void> =>
151
+ new Promise((resolve, reject) => {
133
152
  pm2.delete(MANAGED_SERVER_NAME, (error) => {
134
153
  if (error) {
135
154
  reject(error);
136
155
  return;
137
156
  }
138
-
139
157
  resolve();
140
158
  });
141
159
  });
142
- };
143
160
 
144
161
  /**
145
162
  * Kill the PM2 daemon to clear stale paths/caches.
@@ -158,9 +175,10 @@ const killPm2Daemon = async (): Promise<void> => {
158
175
  * Try to connect to PM2, and if it's in a broken state (stale worktree path),
159
176
  * kill the daemon and reconnect fresh.
160
177
  */
161
- const connectWithRecovery = async (): Promise<void> => {
178
+ const connectWithRecovery = async (): Promise<Pm2Module> => {
162
179
  try {
163
180
  await connectPm2();
181
+ return loadPm2();
164
182
  } catch (error) {
165
183
  if (isPm2BrokenStateError(error)) {
166
184
  console.warn("PM2 daemon is in a stale state. Killing and reconnecting...");
@@ -173,23 +191,25 @@ const connectWithRecovery = async (): Promise<void> => {
173
191
  // Clear cached module so next loadPm2 gets a fresh instance
174
192
  cachedPm2 = null;
175
193
  await connectPm2();
194
+ return loadPm2();
176
195
  } else {
177
196
  throw error;
178
197
  }
179
198
  }
180
199
  };
181
200
 
182
- const withPm2Connection = async <T>(operation: () => Promise<T>): Promise<T> => {
183
- await connectWithRecovery();
201
+ const withPm2Connection = async <T>(operation: (pm2: Pm2Module) => Promise<T>): Promise<T> => {
202
+ const pm2 = await connectWithRecovery();
184
203
 
185
204
  try {
186
- return await operation();
205
+ return await operation(pm2);
187
206
  } finally {
188
207
  await disconnectPm2();
189
208
  }
190
209
  };
191
210
 
192
211
  const waitForRuntimeReady = async (
212
+ pm2: Pm2Module,
193
213
  waitMs: number,
194
214
  logOffsets: StartupLogOffsets
195
215
  ): Promise<void> => {
@@ -200,7 +220,7 @@ const waitForRuntimeReady = async (
200
220
  return;
201
221
  }
202
222
 
203
- const processes = await describeManagedServer();
223
+ const processes = await describeManagedServer(pm2);
204
224
  const process = processes[0];
205
225
  if (!process) {
206
226
  throw createStartupError(
@@ -225,15 +245,52 @@ const waitForRuntimeReady = async (
225
245
  throw createStartupError(`runtime readiness timed out after ${waitMs}ms`, logOffsets);
226
246
  };
227
247
 
228
- const waitForManagedServerExit = async (): Promise<void> => {
229
- while (true) {
230
- const processes = await describeManagedServer();
248
+ const waitForManagedServerDeletion = async (pm2: Pm2Module, waitMs: number): Promise<void> => {
249
+ const deadline = Date.now() + waitMs;
250
+
251
+ while (Date.now() <= deadline) {
252
+ const processes = await describeManagedServer(pm2);
231
253
  if (processes.length === 0) {
232
254
  return;
233
255
  }
234
256
 
235
- await sleep(STARTUP_POLL_INTERVAL_MS);
257
+ const remainingMs = deadline - Date.now();
258
+ if (remainingMs <= 0) {
259
+ break;
260
+ }
261
+
262
+ await sleep(Math.min(STARTUP_POLL_INTERVAL_MS, remainingMs));
236
263
  }
264
+
265
+ throw new Error(`Timed out waiting for the managed server to stop after ${waitMs}ms.`);
266
+ };
267
+
268
+ const deleteManagedServerInSession = async (
269
+ pm2: Pm2Module,
270
+ {
271
+ ignoreMissing = false,
272
+ }: {
273
+ ignoreMissing?: boolean;
274
+ } = {}
275
+ ): Promise<boolean> => {
276
+ const processes = await describeManagedServer(pm2);
277
+ if (processes.length === 0) {
278
+ return false;
279
+ }
280
+
281
+ try {
282
+ await removeManagedServer(pm2);
283
+ } catch (error) {
284
+ if (ignoreMissing && isMissingManagedServerError(error)) {
285
+ await waitForManagedServerDeletion(pm2, PM2_DELETE_WAIT_MS);
286
+ return false;
287
+ }
288
+
289
+ throw error;
290
+ }
291
+
292
+ await waitForManagedServerDeletion(pm2, PM2_DELETE_WAIT_MS);
293
+ return true;
237
294
  };
238
295
 
239
296
  const ensureLogDirectory = (): void => {
@@ -287,47 +344,25 @@ export const deleteManagedServer = async ({
287
344
  }: {
288
345
  ignoreMissing?: boolean;
289
346
  } = {}): Promise<boolean> =>
290
- withPm2Connection(async () => {
291
- const processes = await describeManagedServer();
292
- if (processes.length === 0) {
293
- return false;
294
- }
295
-
296
- try {
297
- await removeManagedServer();
298
- return true;
299
- } catch (error) {
300
- if (ignoreMissing && isMissingManagedServerError(error)) {
301
- return false;
302
- }
303
-
304
- throw error;
305
- }
306
- });
347
+ withPm2Connection((pm2) => deleteManagedServerInSession(pm2, { ignoreMissing }));
307
348
 
308
349
  export const startManagedServer = async ({
309
350
  script,
310
351
  cwd,
311
352
  waitMs,
312
353
  args,
313
- }: StartManagedServerOptions): Promise<void> => {
314
- // First try to delete any existing managed server
315
- await deleteManagedServer({ ignoreMissing: true });
354
+ }: StartManagedServerOptions): Promise<void> =>
355
+ withPm2Connection(async (pm2) => {
356
+ await deleteManagedServerInSession(pm2, { ignoreMissing: true });
316
357
 
317
- // Wait for the old process to actually exit
318
- await withPm2Connection(waitForManagedServerExit);
319
-
320
- // Clear stale runtime config
321
- if (readRuntimeConfig()) {
322
- deleteRuntimeConfig();
323
- }
324
-
325
- ensureLogDirectory();
326
- const { outFile, errFile } = getLogPaths();
327
- const pm2 = await loadPm2();
358
+ if (readRuntimeConfig()) {
359
+ deleteRuntimeConfig();
360
+ }
328
361
 
329
- await withPm2Connection(async () => {
362
+ ensureLogDirectory();
363
+ const { outFile, errFile } = getLogPaths();
330
364
  const logOffsets = captureStartupLogOffsets();
365
+
331
366
  await new Promise<void>((resolve, reject) => {
332
367
  pm2.start(
333
368
  {
@@ -357,13 +392,12 @@ export const startManagedServer = async ({
357
392
  );
358
393
  });
359
394
 
360
- await waitForRuntimeReady(waitMs, logOffsets);
395
+ await waitForRuntimeReady(pm2, waitMs, logOffsets);
361
396
  });
362
- };
363
397
 
364
398
  export const getManagedServerStatus = async (): Promise<ManagedServerStatus> =>
365
- withPm2Connection(async () => {
366
- const processes = await describeManagedServer();
399
+ withPm2Connection(async (pm2) => {
400
+ const processes = await describeManagedServer(pm2);
367
401
  const process = processes[0];
368
402
 
369
403
  if (!process) {
@@ -402,6 +436,14 @@ export const getManagedServerStatus = async (): Promise<ManagedServerStatus> =>
402
436
  };
403
437
  }
404
438
 
439
+ if (pm2Pid === null || pm2Pid === 0) {
440
+ return {
441
+ status: "stopped",
442
+ pm2Pid: null,
443
+ restartCount,
444
+ };
445
+ }
446
+
405
447
  return {
406
448
  status: "errored",
407
449
  pm2Pid,
@@ -149,6 +149,25 @@ describe("server-control", () => {
149
149
  });
150
150
  });
151
151
 
152
+ it("reports stopped when pm2 is no longer running and runtime is absent", async () => {
153
+ getManagedServerStatus.mockResolvedValue({
154
+ status: "starting",
155
+ pm2Pid: null,
156
+ restartCount: 0,
157
+ });
158
+
159
+ await expect(getServerStatus()).resolves.toEqual({
160
+ status: "stopped",
161
+ pid: null,
162
+ host: null,
163
+ port: null,
164
+ restartCount: 0,
165
+ outFile: "/tmp/server.out.log",
166
+ errFile: "/tmp/server.err.log",
167
+ startedAt: null,
168
+ });
169
+ });
170
+
152
171
  it("cleans stale runtime when pm2 reports stopped", async () => {
153
172
  writeRuntimeConfig({
154
173
  host: "127.0.0.1",
@@ -31,7 +31,7 @@ export async function getServerStatus(): Promise<ServerStatus> {
31
31
  const runtime = readRuntimeConfig();
32
32
  const { outFile, errFile } = getLogPaths();
33
33
 
34
- if (managedStatus.status === "stopped") {
34
+ if (managedStatus.status === "stopped" || (managedStatus.pm2Pid === null && runtime === null)) {
35
35
  if (runtime) {
36
36
  deleteRuntimeConfig();
37
37
  }