@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.
- package/CHANGELOG.md +6 -0
- package/README.md +14 -79
- package/dist/esm/bin.mjs +592 -287
- 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/browser.test.ts +50 -0
- package/src/browser.ts +1 -0
- package/src/cli.ts +3 -11
- 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
|
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>
|
package/dist/web/index.html
CHANGED
|
@@ -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/
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
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-
|
|
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.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
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
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync
|
|
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
|
-
|
|
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
|
+
}
|
package/src/pm2-control.test.ts
CHANGED
|
@@ -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(() =>
|
|
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 () => {
|
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
|
}
|