@spencer-kit/coder-studio 0.4.2 → 0.4.3

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.
@@ -6,11 +6,11 @@
6
6
  <meta name="description" content="Coder Studio - Agent-First Development Environment" />
7
7
  <title>Coder Studio</title>
8
8
  <link rel="icon" type="image/x-icon" href="/favicon.ico" />
9
- <script type="module" crossorigin src="/assets/main-D3dXqSaA.js"></script>
9
+ <script type="module" crossorigin src="/assets/main-CEv6hML1.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CpWojdLp.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/monaco-editor-VTbRDH-J.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/xterm-BVlcrOZ1.js">
13
- <link rel="modulepreload" crossorigin href="/assets/components-omWbMLvf.js">
13
+ <link rel="modulepreload" crossorigin href="/assets/components-BZf_jLGv.js">
14
14
  <link rel="stylesheet" crossorigin href="/assets/monaco-editor-Br_kD0ds.css">
15
15
  <link rel="stylesheet" crossorigin href="/assets/xterm-BrP-ENHg.css">
16
16
  <link rel="stylesheet" crossorigin href="/assets/components-AKM1pxhf.css">
@@ -4,11 +4,11 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Coder Studio UI Preview</title>
7
- <script type="module" crossorigin src="/assets/ui-preview-BGZz053-.js"></script>
7
+ <script type="module" crossorigin src="/assets/ui-preview-Cd3euuid.js"></script>
8
8
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CpWojdLp.js">
9
9
  <link rel="modulepreload" crossorigin href="/assets/monaco-editor-VTbRDH-J.js">
10
10
  <link rel="modulepreload" crossorigin href="/assets/xterm-BVlcrOZ1.js">
11
- <link rel="modulepreload" crossorigin href="/assets/components-omWbMLvf.js">
11
+ <link rel="modulepreload" crossorigin href="/assets/components-BZf_jLGv.js">
12
12
  <link rel="stylesheet" crossorigin href="/assets/monaco-editor-Br_kD0ds.css">
13
13
  <link rel="stylesheet" crossorigin href="/assets/xterm-BrP-ENHg.css">
14
14
  <link rel="stylesheet" crossorigin href="/assets/components-AKM1pxhf.css">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spencer-kit/coder-studio",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "type": "module",
5
5
  "description": "Deploy once, code everywhere. Browser-based AI coding workspace for Claude Code and Codex.",
6
6
  "main": "./dist/esm/index.mjs",
@@ -148,14 +148,15 @@ describe("pm2-control", () => {
148
148
  });
149
149
 
150
150
  it("waits for the previous PM2 app to disappear before starting a replacement", async () => {
151
+ const previousPid = 999_990;
151
152
  describeProcess
152
153
  .mockImplementationOnce(
153
154
  (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
154
- callback(null, [{ pid: 111, pm2_env: { status: "online", restart_time: 0 } }])
155
+ callback(null, [{ pid: previousPid, pm2_env: { status: "online", restart_time: 0 } }])
155
156
  )
156
157
  .mockImplementationOnce(
157
158
  (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
158
- callback(null, [{ pid: 111, pm2_env: { status: "stopping", restart_time: 0 } }])
159
+ callback(null, [{ pid: previousPid, pm2_env: { status: "stopping", restart_time: 0 } }])
159
160
  )
160
161
  .mockImplementationOnce(
161
162
  (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
@@ -180,14 +181,15 @@ describe("pm2-control", () => {
180
181
  });
181
182
 
182
183
  it("reuses one pm2 session while polling deletion during startup", async () => {
184
+ const previousPid = 999_992;
183
185
  describeProcess
184
186
  .mockImplementationOnce(
185
187
  (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
186
- callback(null, [{ pid: 111, pm2_env: { status: "online", restart_time: 0 } }])
188
+ callback(null, [{ pid: previousPid, pm2_env: { status: "online", restart_time: 0 } }])
187
189
  )
188
190
  .mockImplementationOnce(
189
191
  (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
190
- callback(null, [{ pid: 111, pm2_env: { status: "stopping", restart_time: 0 } }])
192
+ callback(null, [{ pid: previousPid, pm2_env: { status: "stopping", restart_time: 0 } }])
191
193
  )
192
194
  .mockImplementationOnce(
193
195
  (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
@@ -204,15 +206,55 @@ describe("pm2-control", () => {
204
206
  expect(disconnect).toHaveBeenCalledTimes(1);
205
207
  });
206
208
 
209
+ it("waits for the previous process pid to exit before starting a replacement", async () => {
210
+ const previousPid = 999_991;
211
+ const killSpy = vi.spyOn(process, "kill");
212
+ killSpy
213
+ .mockImplementationOnce(() => true)
214
+ .mockImplementationOnce(() => true)
215
+ .mockImplementationOnce(() => {
216
+ const error = new Error("process not found") as NodeJS.ErrnoException;
217
+ error.code = "ESRCH";
218
+ throw error;
219
+ });
220
+ describeProcess
221
+ .mockImplementationOnce(
222
+ (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
223
+ callback(null, [{ pid: previousPid, pm2_env: { status: "online", restart_time: 0 } }])
224
+ )
225
+ .mockImplementationOnce(
226
+ (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
227
+ callback(null, [])
228
+ );
229
+
230
+ const pendingStart = startManagedServer({
231
+ script: "/cli/dist/esm/server-runner.js",
232
+ cwd: "/repo",
233
+ waitMs: 10,
234
+ });
235
+
236
+ await expect(
237
+ Promise.race([
238
+ pendingStart.then(() => "started"),
239
+ new Promise((resolve) => setTimeout(() => resolve("waiting"), 20)),
240
+ ])
241
+ ).resolves.toBe("waiting");
242
+
243
+ expect(start).not.toHaveBeenCalled();
244
+ await pendingStart;
245
+ expect(killSpy).toHaveBeenCalledWith(previousPid, 0);
246
+ });
247
+
207
248
  it("keeps waiting during startup when delete reports missing but the old app still lingers", async () => {
249
+ const previousPid = 999_993;
208
250
  describeProcess
209
251
  .mockImplementationOnce(
210
252
  (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
211
- callback(null, [{ pid: 111, pm2_env: { status: "online", restart_time: 0 } }])
253
+ callback(null, [{ pid: previousPid, pm2_env: { status: "online", restart_time: 0 } }])
212
254
  )
213
255
  .mockImplementationOnce(
214
256
  (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
215
- callback(null, [{ pid: 111, pm2_env: { status: "stopping", restart_time: 0 } }])
257
+ callback(null, [{ pid: previousPid, pm2_env: { status: "stopping", restart_time: 0 } }])
216
258
  )
217
259
  .mockImplementationOnce(
218
260
  (_name: string, callback: (error: Error | null, result: unknown[]) => void) =>
@@ -265,6 +265,39 @@ const waitForManagedServerDeletion = async (pm2: Pm2Module, waitMs: number): Pro
265
265
  throw new Error(`Timed out waiting for the managed server to stop after ${waitMs}ms.`);
266
266
  };
267
267
 
268
+ const isMissingProcessError = (error: unknown): boolean =>
269
+ Boolean(
270
+ error &&
271
+ typeof error === "object" &&
272
+ "code" in error &&
273
+ (error as NodeJS.ErrnoException).code === "ESRCH"
274
+ );
275
+
276
+ const waitForProcessExit = async (pid: number, waitMs: number): Promise<void> => {
277
+ const deadline = Date.now() + waitMs;
278
+
279
+ while (Date.now() <= deadline) {
280
+ try {
281
+ process.kill(pid, 0);
282
+ } catch (error) {
283
+ if (isMissingProcessError(error)) {
284
+ return;
285
+ }
286
+
287
+ throw error;
288
+ }
289
+
290
+ const remainingMs = deadline - Date.now();
291
+ if (remainingMs <= 0) {
292
+ break;
293
+ }
294
+
295
+ await sleep(Math.min(STARTUP_POLL_INTERVAL_MS, remainingMs));
296
+ }
297
+
298
+ throw new Error(`Timed out waiting for the managed server pid ${pid} to exit after ${waitMs}ms.`);
299
+ };
300
+
268
301
  const deleteManagedServerInSession = async (
269
302
  pm2: Pm2Module,
270
303
  {
@@ -277,19 +310,27 @@ const deleteManagedServerInSession = async (
277
310
  if (processes.length === 0) {
278
311
  return false;
279
312
  }
313
+ const previousPid = processes[0]?.pid;
314
+ const deadline = Date.now() + PM2_DELETE_WAIT_MS;
280
315
 
281
316
  try {
282
317
  await removeManagedServer(pm2);
283
318
  } catch (error) {
284
319
  if (ignoreMissing && isMissingManagedServerError(error)) {
285
- await waitForManagedServerDeletion(pm2, PM2_DELETE_WAIT_MS);
320
+ await waitForManagedServerDeletion(pm2, Math.max(0, deadline - Date.now()));
321
+ if (typeof previousPid === "number" && previousPid > 0) {
322
+ await waitForProcessExit(previousPid, Math.max(0, deadline - Date.now()));
323
+ }
286
324
  return false;
287
325
  }
288
326
 
289
327
  throw error;
290
328
  }
291
329
 
292
- await waitForManagedServerDeletion(pm2, PM2_DELETE_WAIT_MS);
330
+ await waitForManagedServerDeletion(pm2, Math.max(0, deadline - Date.now()));
331
+ if (typeof previousPid === "number" && previousPid > 0) {
332
+ await waitForProcessExit(previousPid, Math.max(0, deadline - Date.now()));
333
+ }
293
334
  return true;
294
335
  };
295
336
 
@@ -96,4 +96,64 @@ describe("update-worker", () => {
96
96
  expect(state.manualCommand).toBe("coder-studio serve --restart");
97
97
  expect(state.errorSummary).toContain("restart failed");
98
98
  });
99
+
100
+ it("sanitizes pm2 and runtime override env before invoking install and restart commands", async () => {
101
+ const env = createEnv();
102
+ const runCommand = vi.fn(async () => {});
103
+ const originalEnv = {
104
+ PM2_HOME: process.env.PM2_HOME,
105
+ PM2_PROGRAMMATIC: process.env.PM2_PROGRAMMATIC,
106
+ PM2_JSON_PROCESSING: process.env.PM2_JSON_PROCESSING,
107
+ PM2_INTERACTOR_PROCESSING: process.env.PM2_INTERACTOR_PROCESSING,
108
+ NODE_APP_INSTANCE: process.env.NODE_APP_INSTANCE,
109
+ NODE_CHANNEL_FD: process.env.NODE_CHANNEL_FD,
110
+ NODE_CHANNEL_SERIALIZATION_MODE: process.env.NODE_CHANNEL_SERIALIZATION_MODE,
111
+ CODER_STUDIO_RUNTIME_JSON_PATH: process.env.CODER_STUDIO_RUNTIME_JSON_PATH,
112
+ CODER_STUDIO_SESSION_ID: process.env.CODER_STUDIO_SESSION_ID,
113
+ CODER_STUDIO_UPDATE_STATE_PATH: process.env.CODER_STUDIO_UPDATE_STATE_PATH,
114
+ pm_id: process.env.pm_id,
115
+ };
116
+
117
+ process.env.PM2_HOME = "/tmp/custom-pm2-home";
118
+ process.env.PM2_PROGRAMMATIC = "true";
119
+ process.env.PM2_JSON_PROCESSING = "true";
120
+ process.env.PM2_INTERACTOR_PROCESSING = "true";
121
+ process.env.NODE_APP_INSTANCE = "0";
122
+ process.env.NODE_CHANNEL_FD = "3";
123
+ process.env.NODE_CHANNEL_SERIALIZATION_MODE = "json";
124
+ process.env.CODER_STUDIO_RUNTIME_JSON_PATH = "/tmp/runtime.json";
125
+ process.env.CODER_STUDIO_SESSION_ID = "sess_test";
126
+ process.env.CODER_STUDIO_UPDATE_STATE_PATH = "/tmp/update-state.json";
127
+ process.env.pm_id = "0";
128
+
129
+ try {
130
+ await runUpdateWorker(env, {
131
+ runCommand,
132
+ now: () => 1000,
133
+ });
134
+ } finally {
135
+ for (const [key, value] of Object.entries(originalEnv)) {
136
+ if (value === undefined) {
137
+ delete process.env[key];
138
+ } else {
139
+ process.env[key] = value;
140
+ }
141
+ }
142
+ }
143
+
144
+ for (const call of runCommand.mock.calls) {
145
+ const options = call[2] as { env?: NodeJS.ProcessEnv };
146
+ expect(options.env?.PM2_HOME).toBe("/tmp/custom-pm2-home");
147
+ expect(options.env?.PM2_PROGRAMMATIC).toBeUndefined();
148
+ expect(options.env?.PM2_JSON_PROCESSING).toBeUndefined();
149
+ expect(options.env?.PM2_INTERACTOR_PROCESSING).toBeUndefined();
150
+ expect(options.env?.NODE_APP_INSTANCE).toBeUndefined();
151
+ expect(options.env?.NODE_CHANNEL_FD).toBeUndefined();
152
+ expect(options.env?.NODE_CHANNEL_SERIALIZATION_MODE).toBeUndefined();
153
+ expect(options.env?.CODER_STUDIO_RUNTIME_JSON_PATH).toBeUndefined();
154
+ expect(options.env?.CODER_STUDIO_SESSION_ID).toBeUndefined();
155
+ expect(options.env?.CODER_STUDIO_UPDATE_STATE_PATH).toBeUndefined();
156
+ expect(options.env?.pm_id).toBeUndefined();
157
+ }
158
+ });
99
159
  });
@@ -97,15 +97,47 @@ function buildManualCommand(input: WorkerEnv): string {
97
97
  ].join("\n");
98
98
  }
99
99
 
100
+ const INTERNAL_ENV_KEYS = new Set([
101
+ "CODER_STUDIO_RUNTIME_JSON_PATH",
102
+ "CODER_STUDIO_SESSION_ID",
103
+ "NODE_APP_INSTANCE",
104
+ "NODE_CHANNEL_FD",
105
+ "NODE_CHANNEL_SERIALIZATION_MODE",
106
+ "PM2_INTERACTOR_PROCESSING",
107
+ "PM2_JSON_PROCESSING",
108
+ "PM2_PROGRAMMATIC",
109
+ ]);
110
+
111
+ function buildChildProcessEnv(env = process.env): NodeJS.ProcessEnv {
112
+ const nextEnv: NodeJS.ProcessEnv = { ...env };
113
+
114
+ for (const key of Object.keys(nextEnv)) {
115
+ if (INTERNAL_ENV_KEYS.has(key)) {
116
+ delete nextEnv[key];
117
+ continue;
118
+ }
119
+
120
+ if (key.startsWith("CODER_STUDIO_UPDATE_") || key.startsWith("pm_")) {
121
+ delete nextEnv[key];
122
+ }
123
+ }
124
+
125
+ return nextEnv;
126
+ }
127
+
100
128
  function runCommand(
101
129
  command: string,
102
130
  args: string[],
103
- options?: { stdio?: "ignore" | "pipe"; logStream?: NodeJS.WritableStream }
131
+ options?: {
132
+ stdio?: "ignore" | "pipe";
133
+ logStream?: NodeJS.WritableStream;
134
+ env?: NodeJS.ProcessEnv;
135
+ }
104
136
  ): Promise<void> {
105
137
  return new Promise((resolve, reject) => {
106
138
  const child = spawn(command, args, {
107
139
  stdio: options?.stdio === "ignore" ? "ignore" : "pipe",
108
- env: process.env,
140
+ env: options?.env ?? process.env,
109
141
  });
110
142
 
111
143
  if (options?.logStream && child.stdout) {
@@ -137,12 +169,13 @@ export async function runUpdateWorker(
137
169
  await mkdir(dirname(input.logFilePath), { recursive: true });
138
170
  const logStream = createWriteStream(input.logFilePath, { flags: "a" });
139
171
  const execute = deps?.runCommand ?? runCommand;
172
+ const childEnv = buildChildProcessEnv(process.env);
140
173
 
141
174
  try {
142
175
  await execute(
143
176
  input.npmCommand,
144
177
  [...input.installArgsPrefix, `${input.packageName}@${input.targetVersion}`],
145
- { logStream }
178
+ { logStream, env: childEnv }
146
179
  );
147
180
  } catch (error) {
148
181
  const message = error instanceof Error ? error.message : String(error);
@@ -183,7 +216,7 @@ export async function runUpdateWorker(
183
216
  });
184
217
 
185
218
  try {
186
- await execute(input.cliCommand, input.restartArgs, { logStream });
219
+ await execute(input.cliCommand, input.restartArgs, { logStream, env: childEnv });
187
220
  } catch (error) {
188
221
  const message = error instanceof Error ? error.message : String(error);
189
222
  await writeState(input.stateFilePath, {