@spencer-kit/coder-studio 0.3.0 → 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.
@@ -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
  }
@@ -1,5 +1,6 @@
1
1
  import { fileURLToPath } from "url";
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { getCliVersion } from "./package-manifest.js";
3
4
 
4
5
  const { createServer, readCliConfig, hasWebAssets, getStaticAssetsDir } = vi.hoisted(() => ({
5
6
  createServer: vi.fn(),
@@ -29,6 +30,17 @@ describe("server-runner", () => {
29
30
  vi.clearAllMocks();
30
31
  });
31
32
 
33
+ it("includes the CLI package version in the server config", () => {
34
+ readCliConfig.mockReturnValue(null);
35
+ hasWebAssets.mockReturnValue(true);
36
+ getStaticAssetsDir.mockReturnValue("/tmp/web");
37
+
38
+ expect(buildServerConfig()).toMatchObject({
39
+ appVersion: getCliVersion(import.meta.url),
40
+ webRoot: "/tmp/web",
41
+ });
42
+ });
43
+
32
44
  it("ignores ephemeral port zero from saved cli config", () => {
33
45
  readCliConfig.mockReturnValue({
34
46
  host: "127.0.0.1",
@@ -40,6 +52,7 @@ describe("server-runner", () => {
40
52
  getStaticAssetsDir.mockReturnValue("/tmp/web");
41
53
 
42
54
  expect(buildServerConfig()).toEqual({
55
+ appVersion: getCliVersion(import.meta.url),
43
56
  host: "127.0.0.1",
44
57
  dataDir: "/tmp/cs-data/coder-studio.db",
45
58
  auth: {
@@ -67,6 +80,7 @@ describe("server-runner", () => {
67
80
  const runningServer = await startServer();
68
81
 
69
82
  expect(createServer).toHaveBeenCalledWith({
83
+ appVersion: getCliVersion(import.meta.url),
70
84
  host: "127.0.0.1",
71
85
  port: 4173,
72
86
  webRoot: "/tmp/web",
@@ -3,12 +3,14 @@ import { fileURLToPath } from "url";
3
3
  import { readCliConfig } from "./config-store.js";
4
4
  import { getStaticAssetsDir, hasWebAssets } from "./embed.js";
5
5
  import { assertSupportedNodeVersion } from "./node-version.js";
6
+ import { getCliVersion } from "./package-manifest.js";
6
7
 
7
8
  const MISSING_WEB_ASSETS_WARNING = "Warning: Web assets not found. Frontend will not be available.";
8
9
 
9
10
  export const buildServerConfig = (): Partial<ServerConfig> => {
10
11
  const savedConfig = readCliConfig();
11
12
  const config: Partial<ServerConfig> = {
13
+ appVersion: getCliVersion(import.meta.url),
12
14
  ...(savedConfig?.host !== undefined ? { host: savedConfig.host } : {}),
13
15
  ...(savedConfig?.port !== undefined && savedConfig.port > 0 ? { port: savedConfig.port } : {}),
14
16
  ...(savedConfig?.dataDir !== undefined ? { dataDir: savedConfig.dataDir } : {}),