@spencer-kit/coder-studio 0.4.3 → 0.4.4

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,14 +6,14 @@
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-CEv6hML1.js"></script>
9
+ <script type="module" crossorigin src="/assets/main-CZuF2VZA.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-BZf_jLGv.js">
13
+ <link rel="modulepreload" crossorigin href="/assets/components-C4SKshs2.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
- <link rel="stylesheet" crossorigin href="/assets/components-AKM1pxhf.css">
16
+ <link rel="stylesheet" crossorigin href="/assets/components-CMahvybm.css">
17
17
  </head>
18
18
  <body>
19
19
  <div id="root"></div>
@@ -4,14 +4,14 @@
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-Cd3euuid.js"></script>
7
+ <script type="module" crossorigin src="/assets/ui-preview-DCeC0YmD.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-BZf_jLGv.js">
11
+ <link rel="modulepreload" crossorigin href="/assets/components-C4SKshs2.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
- <link rel="stylesheet" crossorigin href="/assets/components-AKM1pxhf.css">
14
+ <link rel="stylesheet" crossorigin href="/assets/components-CMahvybm.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spencer-kit/coder-studio",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
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",
@@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
- import { runUpdateWorker } from "./update-worker.js";
5
+ import { runRestartHandoff, runUpdateWorker } from "./update-worker.js";
6
6
 
7
7
  describe("update-worker", () => {
8
8
  const tempDirs: string[] = [];
@@ -29,13 +29,16 @@ describe("update-worker", () => {
29
29
  };
30
30
  }
31
31
 
32
- it("writes restarting state after install success and restart handoff", async () => {
32
+ it("writes restarting state and spawns a detached restart handoff after install success", async () => {
33
33
  const env = createEnv();
34
34
  const runCommand = vi.fn(async () => {});
35
+ const spawnDetachedProcess = vi.fn(async () => {});
35
36
 
36
37
  await runUpdateWorker(env, {
37
38
  runCommand,
38
39
  now: () => 1000,
40
+ processId: 4242,
41
+ spawnDetachedProcess,
39
42
  });
40
43
 
41
44
  const state = JSON.parse(readFileSync(env.stateFilePath, "utf-8")) as { updateStatus: string };
@@ -46,11 +49,13 @@ describe("update-worker", () => {
46
49
  ["install", "-g", "@spencer-kit/coder-studio@0.5.0"],
47
50
  expect.any(Object)
48
51
  );
49
- expect(runCommand).toHaveBeenNthCalledWith(
50
- 2,
51
- "coder-studio",
52
- ["serve", "--restart"],
53
- expect.any(Object)
52
+ expect(spawnDetachedProcess).toHaveBeenCalledWith(
53
+ process.execPath,
54
+ expect.any(Array),
55
+ expect.objectContaining({
56
+ CODER_STUDIO_UPDATE_WORKER_MODE: "restart-handoff",
57
+ CODER_STUDIO_UPDATE_PARENT_PID: "4242",
58
+ })
54
59
  );
55
60
  });
56
61
 
@@ -77,14 +82,14 @@ describe("update-worker", () => {
77
82
 
78
83
  it("marks restart failures with manual restart guidance", async () => {
79
84
  const env = createEnv();
80
- const runCommand = vi
81
- .fn()
82
- .mockResolvedValueOnce(undefined)
83
- .mockRejectedValueOnce(new Error("pm2 restart failed"));
85
+ const runCommand = vi.fn().mockRejectedValueOnce(new Error("pm2 restart failed"));
86
+ const waitForProcessExit = vi.fn(async () => {});
84
87
 
85
- await runUpdateWorker(env, {
88
+ await runRestartHandoff(env, {
86
89
  runCommand,
87
90
  now: () => 1000,
91
+ waitForProcessExit,
92
+ restartParentPid: 999,
88
93
  });
89
94
 
90
95
  const state = JSON.parse(readFileSync(env.stateFilePath, "utf-8")) as {
@@ -95,11 +100,13 @@ describe("update-worker", () => {
95
100
  expect(state.updateStatus).toBe("failed");
96
101
  expect(state.manualCommand).toBe("coder-studio serve --restart");
97
102
  expect(state.errorSummary).toContain("restart failed");
103
+ expect(waitForProcessExit).toHaveBeenCalledWith(999);
98
104
  });
99
105
 
100
106
  it("sanitizes pm2 and runtime override env before invoking install and restart commands", async () => {
101
107
  const env = createEnv();
102
108
  const runCommand = vi.fn(async () => {});
109
+ const spawnDetachedProcess = vi.fn(async () => {});
103
110
  const originalEnv = {
104
111
  PM2_HOME: process.env.PM2_HOME,
105
112
  PM2_PROGRAMMATIC: process.env.PM2_PROGRAMMATIC,
@@ -130,6 +137,8 @@ describe("update-worker", () => {
130
137
  await runUpdateWorker(env, {
131
138
  runCommand,
132
139
  now: () => 1000,
140
+ processId: 4242,
141
+ spawnDetachedProcess,
133
142
  });
134
143
  } finally {
135
144
  for (const [key, value] of Object.entries(originalEnv)) {
@@ -155,5 +164,37 @@ describe("update-worker", () => {
155
164
  expect(options.env?.CODER_STUDIO_UPDATE_STATE_PATH).toBeUndefined();
156
165
  expect(options.env?.pm_id).toBeUndefined();
157
166
  }
167
+
168
+ const handoffEnv = spawnDetachedProcess.mock.calls[0]?.[2] as NodeJS.ProcessEnv | undefined;
169
+ expect(handoffEnv?.PM2_HOME).toBe("/tmp/custom-pm2-home");
170
+ expect(handoffEnv?.PM2_PROGRAMMATIC).toBeUndefined();
171
+ expect(handoffEnv?.PM2_JSON_PROCESSING).toBeUndefined();
172
+ expect(handoffEnv?.PM2_INTERACTOR_PROCESSING).toBeUndefined();
173
+ expect(handoffEnv?.NODE_APP_INSTANCE).toBeUndefined();
174
+ expect(handoffEnv?.NODE_CHANNEL_FD).toBeUndefined();
175
+ expect(handoffEnv?.NODE_CHANNEL_SERIALIZATION_MODE).toBeUndefined();
176
+ expect(handoffEnv?.CODER_STUDIO_RUNTIME_JSON_PATH).toBeUndefined();
177
+ expect(handoffEnv?.CODER_STUDIO_SESSION_ID).toBeUndefined();
178
+ expect(handoffEnv?.pm_id).toBeUndefined();
179
+ });
180
+
181
+ it("waits for the install worker to exit before running the restart command", async () => {
182
+ const env = createEnv();
183
+ const waitForProcessExit = vi.fn(async () => {});
184
+ const runCommand = vi.fn(async () => {});
185
+
186
+ await runRestartHandoff(env, {
187
+ runCommand,
188
+ now: () => 1000,
189
+ waitForProcessExit,
190
+ restartParentPid: 777,
191
+ });
192
+
193
+ expect(waitForProcessExit).toHaveBeenCalledWith(777);
194
+ expect(runCommand).toHaveBeenCalledWith(
195
+ "coder-studio",
196
+ ["serve", "--restart"],
197
+ expect.any(Object)
198
+ );
158
199
  });
159
200
  });
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { createWriteStream } from "node:fs";
3
3
  import { mkdir } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
 
6
7
  interface UpdateStateSnapshot {
7
8
  version: 1;
@@ -37,6 +38,13 @@ interface WorkerEnv {
37
38
  installArgsPrefix: string[];
38
39
  }
39
40
 
41
+ type WorkerMode = "install" | "restart-handoff";
42
+
43
+ const RESTART_HANDOFF_MODE: WorkerMode = "restart-handoff";
44
+ const DEFAULT_MODE: WorkerMode = "install";
45
+ const RESTART_HANDOFF_WAIT_MS = 5_000;
46
+ const WORKER_ENTRY_PATH = fileURLToPath(import.meta.url);
47
+
40
48
  async function writeState(filePath: string, value: UpdateStateSnapshot): Promise<void> {
41
49
  await mkdir(dirname(filePath), { recursive: true });
42
50
  await import("node:fs/promises").then(({ writeFile }) =>
@@ -44,6 +52,16 @@ async function writeState(filePath: string, value: UpdateStateSnapshot): Promise
44
52
  );
45
53
  }
46
54
 
55
+ function closeLogStream(stream: NodeJS.WritableStream): Promise<void> {
56
+ return new Promise((resolve, reject) => {
57
+ stream.once("error", reject);
58
+ stream.end(() => {
59
+ stream.off("error", reject);
60
+ resolve();
61
+ });
62
+ });
63
+ }
64
+
47
65
  function parseJsonArray(value: string | undefined, fallback: string[]): string[] {
48
66
  if (!value) {
49
67
  return fallback;
@@ -97,6 +115,22 @@ function buildManualCommand(input: WorkerEnv): string {
97
115
  ].join("\n");
98
116
  }
99
117
 
118
+ function readWorkerMode(env = process.env): WorkerMode {
119
+ return env.CODER_STUDIO_UPDATE_WORKER_MODE === RESTART_HANDOFF_MODE
120
+ ? RESTART_HANDOFF_MODE
121
+ : DEFAULT_MODE;
122
+ }
123
+
124
+ function readRestartParentPid(env = process.env): number | null {
125
+ const raw = env.CODER_STUDIO_UPDATE_PARENT_PID;
126
+ if (!raw) {
127
+ return null;
128
+ }
129
+
130
+ const pid = Number.parseInt(raw, 10);
131
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
132
+ }
133
+
100
134
  const INTERNAL_ENV_KEYS = new Set([
101
135
  "CODER_STUDIO_RUNTIME_JSON_PATH",
102
136
  "CODER_STUDIO_SESSION_ID",
@@ -125,6 +159,71 @@ function buildChildProcessEnv(env = process.env): NodeJS.ProcessEnv {
125
159
  return nextEnv;
126
160
  }
127
161
 
162
+ function buildWorkerEnv(input: WorkerEnv): NodeJS.ProcessEnv {
163
+ return {
164
+ CODER_STUDIO_UPDATE_STATE_PATH: input.stateFilePath,
165
+ CODER_STUDIO_UPDATE_LOG_PATH: input.logFilePath,
166
+ CODER_STUDIO_UPDATE_PACKAGE_NAME: input.packageName,
167
+ CODER_STUDIO_UPDATE_TARGET_VERSION: input.targetVersion,
168
+ CODER_STUDIO_UPDATE_CLI_COMMAND: input.cliCommand,
169
+ CODER_STUDIO_UPDATE_CURRENT_VERSION: input.currentVersion,
170
+ CODER_STUDIO_UPDATE_NPM_COMMAND: input.npmCommand,
171
+ CODER_STUDIO_UPDATE_RESTART_ARGS: JSON.stringify(input.restartArgs),
172
+ CODER_STUDIO_UPDATE_INSTALL_ARGS_PREFIX: JSON.stringify(input.installArgsPrefix),
173
+ };
174
+ }
175
+
176
+ function spawnDetachedProcess(
177
+ command: string,
178
+ args: string[],
179
+ env: NodeJS.ProcessEnv
180
+ ): Promise<void> {
181
+ return new Promise((resolve, reject) => {
182
+ const child = spawn(command, args, {
183
+ detached: true,
184
+ stdio: "ignore",
185
+ env,
186
+ });
187
+
188
+ child.on("error", reject);
189
+ child.unref();
190
+ resolve();
191
+ });
192
+ }
193
+
194
+ const isMissingProcessError = (error: unknown): boolean =>
195
+ Boolean(
196
+ error &&
197
+ typeof error === "object" &&
198
+ "code" in error &&
199
+ (error as NodeJS.ErrnoException).code === "ESRCH"
200
+ );
201
+
202
+ async function waitForProcessExit(pid: number, waitMs = RESTART_HANDOFF_WAIT_MS): Promise<void> {
203
+ const deadline = Date.now() + waitMs;
204
+
205
+ while (Date.now() <= deadline) {
206
+ try {
207
+ process.kill(pid, 0);
208
+ } catch (error) {
209
+ if (isMissingProcessError(error)) {
210
+ return;
211
+ }
212
+
213
+ throw error;
214
+ }
215
+
216
+ const remainingMs = deadline - Date.now();
217
+ if (remainingMs <= 0) {
218
+ break;
219
+ }
220
+
221
+ await new Promise((resolve) => {
222
+ setTimeout(resolve, Math.min(100, remainingMs));
223
+ });
224
+ }
225
+ }
226
+
128
227
  function runCommand(
129
228
  command: string,
130
229
  args: string[],
@@ -163,6 +262,8 @@ export async function runUpdateWorker(
163
262
  deps?: {
164
263
  runCommand?: typeof runCommand;
165
264
  now?: () => number;
265
+ processId?: number;
266
+ spawnDetachedProcess?: typeof spawnDetachedProcess;
166
267
  }
167
268
  ): Promise<void> {
168
269
  const now = deps?.now ?? Date.now;
@@ -170,6 +271,8 @@ export async function runUpdateWorker(
170
271
  const logStream = createWriteStream(input.logFilePath, { flags: "a" });
171
272
  const execute = deps?.runCommand ?? runCommand;
172
273
  const childEnv = buildChildProcessEnv(process.env);
274
+ const processId = deps?.processId ?? process.pid;
275
+ const spawnRestartHandoff = deps?.spawnDetachedProcess ?? spawnDetachedProcess;
173
276
 
174
277
  try {
175
278
  await execute(
@@ -196,7 +299,7 @@ export async function runUpdateWorker(
196
299
  manualCommand: permissionRelated ? buildManualCommand(input) : null,
197
300
  errorSummary: message,
198
301
  });
199
- logStream.end();
302
+ await closeLogStream(logStream);
200
303
  return;
201
304
  }
202
305
 
@@ -216,6 +319,55 @@ export async function runUpdateWorker(
216
319
  });
217
320
 
218
321
  try {
322
+ await spawnRestartHandoff(process.execPath, [WORKER_ENTRY_PATH], {
323
+ ...childEnv,
324
+ ...buildWorkerEnv(input),
325
+ CODER_STUDIO_UPDATE_WORKER_MODE: RESTART_HANDOFF_MODE,
326
+ CODER_STUDIO_UPDATE_PARENT_PID: String(processId),
327
+ });
328
+ } catch (error) {
329
+ const message = error instanceof Error ? error.message : String(error);
330
+ await writeState(input.stateFilePath, {
331
+ version: 1,
332
+ currentVersion: input.currentVersion,
333
+ latestVersion: input.targetVersion,
334
+ availability: "update_available",
335
+ updateStatus: "failed",
336
+ lastCheckedAt: now(),
337
+ targetVersion: input.targetVersion,
338
+ startedAt: now(),
339
+ finishedAt: now(),
340
+ requiresManualStep: true,
341
+ manualCommand: `${input.cliCommand} ${input.restartArgs.join(" ")}`,
342
+ errorSummary: `new version installed but service restart failed: ${message}`,
343
+ });
344
+ } finally {
345
+ await closeLogStream(logStream);
346
+ }
347
+ }
348
+
349
+ export async function runRestartHandoff(
350
+ input = readEnv(),
351
+ deps?: {
352
+ runCommand?: typeof runCommand;
353
+ now?: () => number;
354
+ waitForProcessExit?: typeof waitForProcessExit;
355
+ restartParentPid?: number | null;
356
+ }
357
+ ): Promise<void> {
358
+ const now = deps?.now ?? Date.now;
359
+ await mkdir(dirname(input.logFilePath), { recursive: true });
360
+ const logStream = createWriteStream(input.logFilePath, { flags: "a" });
361
+ const execute = deps?.runCommand ?? runCommand;
362
+ const waitForParentExit = deps?.waitForProcessExit ?? waitForProcessExit;
363
+ const childEnv = buildChildProcessEnv(process.env);
364
+ const restartParentPid = deps?.restartParentPid ?? readRestartParentPid(process.env);
365
+
366
+ try {
367
+ if (restartParentPid !== null) {
368
+ await waitForParentExit(restartParentPid);
369
+ }
370
+
219
371
  await execute(input.cliCommand, input.restartArgs, { logStream, env: childEnv });
220
372
  } catch (error) {
221
373
  const message = error instanceof Error ? error.message : String(error);
@@ -234,12 +386,15 @@ export async function runUpdateWorker(
234
386
  errorSummary: `new version installed but service restart failed: ${message}`,
235
387
  });
236
388
  } finally {
237
- logStream.end();
389
+ await closeLogStream(logStream);
238
390
  }
239
391
  }
240
392
 
241
393
  if (process.env.CODER_STUDIO_UPDATE_STATE_PATH) {
242
- void runUpdateWorker().catch((error) => {
394
+ const run =
395
+ readWorkerMode(process.env) === RESTART_HANDOFF_MODE ? runRestartHandoff : runUpdateWorker;
396
+
397
+ void run().catch((error) => {
243
398
  console.error("[update-worker]", error);
244
399
  process.exitCode = 1;
245
400
  });