@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.
- package/CHANGELOG.md +12 -0
- package/README.md +14 -79
- package/dist/esm/bin.mjs +572 -284
- package/dist/esm/bin.mjs.map +4 -4
- package/dist/esm/server-runner.mjs +450 -168
- package/dist/esm/server-runner.mjs.map +4 -4
- package/dist/web/assets/index-BjrMfcUG.js +111 -0
- package/dist/web/assets/index-BjrMfcUG.js.map +1 -0
- package/dist/web/assets/index-mL_Aq31j.css +1 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/favicon.png +0 -0
- package/dist/web/favicon.svg +19 -0
- package/dist/web/index.html +4 -4
- package/package.json +2 -2
- package/src/bin.test.ts +1 -1
- package/src/bin.ts +6 -379
- package/src/browser.test.ts +50 -0
- package/src/browser.ts +1 -0
- package/src/cli.ts +347 -0
- package/src/package-manifest.test.ts +14 -0
- package/src/package-manifest.ts +28 -0
- package/src/pm2-control.test.ts +89 -1
- package/src/pm2-control.ts +99 -57
- package/src/server-control.test.ts +19 -0
- package/src/server-control.ts +1 -1
- package/src/server-runner.test.ts +14 -0
- package/src/server-runner.ts +2 -0
- package/dist/web/assets/index-A-2YPePM.js +0 -111
- package/dist/web/assets/index-A-2YPePM.js.map +0 -1
- package/dist/web/assets/index-BLMivSS1.css +0 -1
package/src/pm2-control.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
315
|
-
|
|
354
|
+
}: StartManagedServerOptions): Promise<void> =>
|
|
355
|
+
withPm2Connection(async (pm2) => {
|
|
356
|
+
await deleteManagedServerInSession(pm2, { ignoreMissing: true });
|
|
316
357
|
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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",
|
package/src/server-control.ts
CHANGED
|
@@ -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",
|
package/src/server-runner.ts
CHANGED
|
@@ -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 } : {}),
|