create-assistant-ui 0.0.75 → 0.0.76

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/dist/index.js CHANGED
@@ -1,8 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { main } from "./run.js";
3
3
  //#region src/index.ts
4
- process.on("SIGINT", () => process.exit(0));
5
- process.on("SIGTERM", () => process.exit(0));
6
4
  main();
7
5
  //#endregion
8
6
 
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { main } from \"./run\";\n\nprocess.on(\"SIGINT\", () => process.exit(0));\nprocess.on(\"SIGTERM\", () => process.exit(0));\n\nvoid main();\n"],"mappings":";;;AAGA,QAAQ,GAAG,gBAAgB,QAAQ,KAAK,CAAC,CAAC;AAC1C,QAAQ,GAAG,iBAAiB,QAAQ,KAAK,CAAC,CAAC;AAEtC,KAAK"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { main } from \"./run\";\n\nvoid main();\n"],"mappings":";;;AAGK,KAAK"}
package/dist/run.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  //#region src/run.d.ts
2
+ declare function runSpawn(command: string, args: string[]): Promise<void>;
2
3
  declare function main(): Promise<void>;
3
4
  //#endregion
4
- export { main };
5
+ export { main, runSpawn };
5
6
  //# sourceMappingURL=run.d.ts.map
package/dist/run.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"run.d.ts","names":[],"sources":["../src/run.ts"],"mappings":";iBAoDsB,QAAQ"}
1
+ {"version":3,"file":"run.d.ts","names":[],"sources":["../src/run.ts"],"mappings":";iBA4BgB,SAAS,iBAAiB,iBAAiB;iBAqErC,QAAQ"}
package/dist/run.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "cross-spawn";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { createRequire } from "node:module";
4
+ import { constants } from "node:os";
4
5
  import path from "node:path";
5
6
  //#region src/run.ts
6
7
  const require = createRequire(import.meta.url);
@@ -11,11 +12,51 @@ var SpawnExitError = class extends Error {
11
12
  this.code = code;
12
13
  }
13
14
  };
15
+ var SpawnSignalError = class extends Error {
16
+ signal;
17
+ forwarded;
18
+ constructor(signal, forwarded) {
19
+ super(`Process terminated by ${signal}`);
20
+ this.signal = signal;
21
+ this.forwarded = forwarded;
22
+ }
23
+ };
14
24
  function runSpawn(command, args) {
15
25
  return new Promise((resolve, reject) => {
16
26
  const child = spawn(command, args, { stdio: "inherit" });
17
- child.on("error", (error) => reject(error));
18
- child.on("close", (code) => {
27
+ let forwardedSignal = null;
28
+ const forwardSignal = (signal) => {
29
+ if (forwardedSignal !== null) {
30
+ child.kill("SIGKILL");
31
+ cleanup();
32
+ reject(new SpawnSignalError(signal, true));
33
+ return;
34
+ }
35
+ forwardedSignal = signal;
36
+ child.kill(signal);
37
+ };
38
+ const onSigint = () => forwardSignal("SIGINT");
39
+ const onSigterm = () => forwardSignal("SIGTERM");
40
+ const cleanup = () => {
41
+ process.off("SIGINT", onSigint);
42
+ process.off("SIGTERM", onSigterm);
43
+ };
44
+ process.on("SIGINT", onSigint);
45
+ process.on("SIGTERM", onSigterm);
46
+ child.on("error", (error) => {
47
+ cleanup();
48
+ reject(error);
49
+ });
50
+ child.on("close", (code, signal) => {
51
+ cleanup();
52
+ if (forwardedSignal !== null) {
53
+ reject(new SpawnSignalError(forwardedSignal, true));
54
+ return;
55
+ }
56
+ if (signal !== null) {
57
+ reject(new SpawnSignalError(signal, false));
58
+ return;
59
+ }
19
60
  if (code !== 0) reject(new SpawnExitError(code || 1));
20
61
  else resolve();
21
62
  });
@@ -36,6 +77,13 @@ async function main() {
36
77
  if (args[0] !== "create") args.unshift("create");
37
78
  await runSpawn(process.execPath, [assistantUiBinPath, ...args]);
38
79
  } catch (error) {
80
+ if (error instanceof SpawnSignalError) {
81
+ if (error.forwarded) {
82
+ process.removeAllListeners(error.signal);
83
+ process.kill(process.pid, error.signal);
84
+ }
85
+ process.exit(128 + (constants.signals[error.signal] ?? 0));
86
+ }
39
87
  if (error instanceof SpawnExitError) process.exit(error.code);
40
88
  const message = error instanceof Error ? error.message : String(error);
41
89
  console.error(`Error: ${message}`);
@@ -43,6 +91,6 @@ async function main() {
43
91
  }
44
92
  }
45
93
  //#endregion
46
- export { main };
94
+ export { main, runSpawn };
47
95
 
48
96
  //# sourceMappingURL=run.js.map
package/dist/run.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"run.js","names":[],"sources":["../src/run.ts"],"sourcesContent":["import { spawn } from \"cross-spawn\";\nimport { readFile } from \"node:fs/promises\";\nimport { createRequire } from \"node:module\";\nimport path from \"node:path\";\n\nconst require = createRequire(import.meta.url);\n\nclass SpawnExitError extends Error {\n code: number;\n\n constructor(code: number) {\n super(`Process exited with code ${code}`);\n this.code = code;\n }\n}\n\nfunction runSpawn(command: string, args: string[]): Promise<void> {\n return new Promise((resolve, reject) => {\n const child = spawn(command, args, {\n stdio: \"inherit\",\n });\n\n child.on(\"error\", (error) => reject(error));\n child.on(\"close\", (code) => {\n if (code !== 0) {\n reject(new SpawnExitError(code || 1));\n } else {\n resolve();\n }\n });\n });\n}\n\nasync function resolveAssistantUiBinPath(): Promise<string> {\n const packageJsonPath = require.resolve(\"assistant-ui/package.json\");\n const packageJsonRaw = await readFile(packageJsonPath, \"utf8\");\n const packageJson = JSON.parse(packageJsonRaw) as {\n bin?: string | Record<string, string>;\n };\n\n const bin =\n typeof packageJson.bin === \"string\"\n ? packageJson.bin\n : packageJson.bin?.[\"assistant-ui\"];\n\n if (!bin) {\n throw new Error(\"assistant-ui package does not expose a binary.\");\n }\n\n return path.resolve(path.dirname(packageJsonPath), bin);\n}\n\nexport async function main(): Promise<void> {\n try {\n const assistantUiBinPath = await resolveAssistantUiBinPath();\n\n const args = process.argv.slice(2);\n if (args[0] !== \"create\") {\n args.unshift(\"create\");\n }\n\n await runSpawn(process.execPath, [assistantUiBinPath, ...args]);\n } catch (error) {\n if (error instanceof SpawnExitError) {\n process.exit(error.code);\n }\n\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Error: ${message}`);\n process.exit(1);\n }\n}\n"],"mappings":";;;;;AAKA,MAAM,UAAU,cAAc,YAAY,GAAG;AAE7C,IAAM,iBAAN,cAA6B,MAAM;CACjC;CAEA,YAAY,MAAc;EACxB,MAAM,4BAA4B,MAAM;EACxC,KAAK,OAAO;CACd;AACF;AAEA,SAAS,SAAS,SAAiB,MAA+B;CAChE,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,QAAQ,MAAM,SAAS,MAAM,EACjC,OAAO,UACT,CAAC;EAED,MAAM,GAAG,UAAU,UAAU,OAAO,KAAK,CAAC;EAC1C,MAAM,GAAG,UAAU,SAAS;GAC1B,IAAI,SAAS,GACX,OAAO,IAAI,eAAe,QAAQ,CAAC,CAAC;QAEpC,QAAQ;EAEZ,CAAC;CACH,CAAC;AACH;AAEA,eAAe,4BAA6C;CAC1D,MAAM,kBAAkB,QAAQ,QAAQ,2BAA2B;CACnE,MAAM,iBAAiB,MAAM,SAAS,iBAAiB,MAAM;CAC7D,MAAM,cAAc,KAAK,MAAM,cAAc;CAI7C,MAAM,MACJ,OAAO,YAAY,QAAQ,WACvB,YAAY,MACZ,YAAY,MAAM;CAExB,IAAI,CAAC,KACH,MAAM,IAAI,MAAM,gDAAgD;CAGlE,OAAO,KAAK,QAAQ,KAAK,QAAQ,eAAe,GAAG,GAAG;AACxD;AAEA,eAAsB,OAAsB;CAC1C,IAAI;EACF,MAAM,qBAAqB,MAAM,0BAA0B;EAE3D,MAAM,OAAO,QAAQ,KAAK,MAAM,CAAC;EACjC,IAAI,KAAK,OAAO,UACd,KAAK,QAAQ,QAAQ;EAGvB,MAAM,SAAS,QAAQ,UAAU,CAAC,oBAAoB,GAAG,IAAI,CAAC;CAChE,SAAS,OAAO;EACd,IAAI,iBAAiB,gBACnB,QAAQ,KAAK,MAAM,IAAI;EAGzB,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EACrE,QAAQ,MAAM,UAAU,SAAS;EACjC,QAAQ,KAAK,CAAC;CAChB;AACF"}
1
+ {"version":3,"file":"run.js","names":[],"sources":["../src/run.ts"],"sourcesContent":["import { spawn } from \"cross-spawn\";\nimport { readFile } from \"node:fs/promises\";\nimport { createRequire } from \"node:module\";\nimport { constants } from \"node:os\";\nimport path from \"node:path\";\n\nconst require = createRequire(import.meta.url);\n\nclass SpawnExitError extends Error {\n code: number;\n\n constructor(code: number) {\n super(`Process exited with code ${code}`);\n this.code = code;\n }\n}\n\nclass SpawnSignalError extends Error {\n signal: NodeJS.Signals;\n forwarded: boolean;\n\n constructor(signal: NodeJS.Signals, forwarded: boolean) {\n super(`Process terminated by ${signal}`);\n this.signal = signal;\n this.forwarded = forwarded;\n }\n}\n\nexport function runSpawn(command: string, args: string[]): Promise<void> {\n return new Promise((resolve, reject) => {\n const child = spawn(command, args, {\n stdio: \"inherit\",\n });\n let forwardedSignal: NodeJS.Signals | null = null;\n\n const forwardSignal = (signal: NodeJS.Signals) => {\n if (forwardedSignal !== null) {\n child.kill(\"SIGKILL\");\n cleanup();\n reject(new SpawnSignalError(signal, true));\n return;\n }\n forwardedSignal = signal;\n child.kill(signal);\n };\n const onSigint = () => forwardSignal(\"SIGINT\");\n const onSigterm = () => forwardSignal(\"SIGTERM\");\n const cleanup = () => {\n process.off(\"SIGINT\", onSigint);\n process.off(\"SIGTERM\", onSigterm);\n };\n\n process.on(\"SIGINT\", onSigint);\n process.on(\"SIGTERM\", onSigterm);\n\n child.on(\"error\", (error) => {\n cleanup();\n reject(error);\n });\n child.on(\"close\", (code, signal) => {\n cleanup();\n if (forwardedSignal !== null) {\n reject(new SpawnSignalError(forwardedSignal, true));\n return;\n }\n if (signal !== null) {\n reject(new SpawnSignalError(signal, false));\n return;\n }\n if (code !== 0) {\n reject(new SpawnExitError(code || 1));\n } else {\n resolve();\n }\n });\n });\n}\n\nasync function resolveAssistantUiBinPath(): Promise<string> {\n const packageJsonPath = require.resolve(\"assistant-ui/package.json\");\n const packageJsonRaw = await readFile(packageJsonPath, \"utf8\");\n const packageJson = JSON.parse(packageJsonRaw) as {\n bin?: string | Record<string, string>;\n };\n\n const bin =\n typeof packageJson.bin === \"string\"\n ? packageJson.bin\n : packageJson.bin?.[\"assistant-ui\"];\n\n if (!bin) {\n throw new Error(\"assistant-ui package does not expose a binary.\");\n }\n\n return path.resolve(path.dirname(packageJsonPath), bin);\n}\n\nexport async function main(): Promise<void> {\n try {\n const assistantUiBinPath = await resolveAssistantUiBinPath();\n\n const args = process.argv.slice(2);\n if (args[0] !== \"create\") {\n args.unshift(\"create\");\n }\n\n await runSpawn(process.execPath, [assistantUiBinPath, ...args]);\n } catch (error) {\n if (error instanceof SpawnSignalError) {\n if (error.forwarded) {\n process.removeAllListeners(error.signal);\n process.kill(process.pid, error.signal);\n }\n process.exit(128 + (constants.signals[error.signal] ?? 0));\n }\n\n if (error instanceof SpawnExitError) {\n process.exit(error.code);\n }\n\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Error: ${message}`);\n process.exit(1);\n }\n}\n"],"mappings":";;;;;;AAMA,MAAM,UAAU,cAAc,YAAY,GAAG;AAE7C,IAAM,iBAAN,cAA6B,MAAM;CACjC;CAEA,YAAY,MAAc;EACxB,MAAM,4BAA4B,MAAM;EACxC,KAAK,OAAO;CACd;AACF;AAEA,IAAM,mBAAN,cAA+B,MAAM;CACnC;CACA;CAEA,YAAY,QAAwB,WAAoB;EACtD,MAAM,yBAAyB,QAAQ;EACvC,KAAK,SAAS;EACd,KAAK,YAAY;CACnB;AACF;AAEA,SAAgB,SAAS,SAAiB,MAA+B;CACvE,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,QAAQ,MAAM,SAAS,MAAM,EACjC,OAAO,UACT,CAAC;EACD,IAAI,kBAAyC;EAE7C,MAAM,iBAAiB,WAA2B;GAChD,IAAI,oBAAoB,MAAM;IAC5B,MAAM,KAAK,SAAS;IACpB,QAAQ;IACR,OAAO,IAAI,iBAAiB,QAAQ,IAAI,CAAC;IACzC;GACF;GACA,kBAAkB;GAClB,MAAM,KAAK,MAAM;EACnB;EACA,MAAM,iBAAiB,cAAc,QAAQ;EAC7C,MAAM,kBAAkB,cAAc,SAAS;EAC/C,MAAM,gBAAgB;GACpB,QAAQ,IAAI,UAAU,QAAQ;GAC9B,QAAQ,IAAI,WAAW,SAAS;EAClC;EAEA,QAAQ,GAAG,UAAU,QAAQ;EAC7B,QAAQ,GAAG,WAAW,SAAS;EAE/B,MAAM,GAAG,UAAU,UAAU;GAC3B,QAAQ;GACR,OAAO,KAAK;EACd,CAAC;EACD,MAAM,GAAG,UAAU,MAAM,WAAW;GAClC,QAAQ;GACR,IAAI,oBAAoB,MAAM;IAC5B,OAAO,IAAI,iBAAiB,iBAAiB,IAAI,CAAC;IAClD;GACF;GACA,IAAI,WAAW,MAAM;IACnB,OAAO,IAAI,iBAAiB,QAAQ,KAAK,CAAC;IAC1C;GACF;GACA,IAAI,SAAS,GACX,OAAO,IAAI,eAAe,QAAQ,CAAC,CAAC;QAEpC,QAAQ;EAEZ,CAAC;CACH,CAAC;AACH;AAEA,eAAe,4BAA6C;CAC1D,MAAM,kBAAkB,QAAQ,QAAQ,2BAA2B;CACnE,MAAM,iBAAiB,MAAM,SAAS,iBAAiB,MAAM;CAC7D,MAAM,cAAc,KAAK,MAAM,cAAc;CAI7C,MAAM,MACJ,OAAO,YAAY,QAAQ,WACvB,YAAY,MACZ,YAAY,MAAM;CAExB,IAAI,CAAC,KACH,MAAM,IAAI,MAAM,gDAAgD;CAGlE,OAAO,KAAK,QAAQ,KAAK,QAAQ,eAAe,GAAG,GAAG;AACxD;AAEA,eAAsB,OAAsB;CAC1C,IAAI;EACF,MAAM,qBAAqB,MAAM,0BAA0B;EAE3D,MAAM,OAAO,QAAQ,KAAK,MAAM,CAAC;EACjC,IAAI,KAAK,OAAO,UACd,KAAK,QAAQ,QAAQ;EAGvB,MAAM,SAAS,QAAQ,UAAU,CAAC,oBAAoB,GAAG,IAAI,CAAC;CAChE,SAAS,OAAO;EACd,IAAI,iBAAiB,kBAAkB;GACrC,IAAI,MAAM,WAAW;IACnB,QAAQ,mBAAmB,MAAM,MAAM;IACvC,QAAQ,KAAK,QAAQ,KAAK,MAAM,MAAM;GACxC;GACA,QAAQ,KAAK,OAAO,UAAU,QAAQ,MAAM,WAAW,EAAE;EAC3D;EAEA,IAAI,iBAAiB,gBACnB,QAAQ,KAAK,MAAM,IAAI;EAGzB,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EACrE,QAAQ,MAAM,UAAU,SAAS;EACjC,QAAQ,KAAK,CAAC;CAChB;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-assistant-ui",
3
- "version": "0.0.75",
3
+ "version": "0.0.76",
4
4
  "description": "Create assistant-ui apps with one command",
5
5
  "keywords": [
6
6
  "cli",
@@ -25,12 +25,13 @@
25
25
  ],
26
26
  "sideEffects": false,
27
27
  "dependencies": {
28
- "cross-spawn": "^7.0.6",
29
- "assistant-ui": "0.0.112"
28
+ "assistant-ui": "0.0.113",
29
+ "cross-spawn": "^7.0.6"
30
30
  },
31
31
  "devDependencies": {
32
+ "@assistant-ui/x-buildutils": "0.0.24",
32
33
  "@types/cross-spawn": "^6.0.6",
33
- "@assistant-ui/x-buildutils": "0.0.23"
34
+ "vitest": "^4.1.11"
34
35
  },
35
36
  "publishConfig": {
36
37
  "access": "public",
@@ -46,6 +47,7 @@
46
47
  "url": "https://github.com/assistant-ui/assistant-ui/issues"
47
48
  },
48
49
  "scripts": {
49
- "build": "aui-build"
50
+ "build": "aui-build",
51
+ "test": "vitest run"
50
52
  }
51
53
  }
package/src/index.ts CHANGED
@@ -1,7 +1,4 @@
1
1
  #!/usr/bin/env node
2
2
  import { main } from "./run";
3
3
 
4
- process.on("SIGINT", () => process.exit(0));
5
- process.on("SIGTERM", () => process.exit(0));
6
-
7
4
  void main();
@@ -0,0 +1,155 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ spawn: vi.fn(),
6
+ }));
7
+
8
+ vi.mock("cross-spawn", async (importOriginal) => ({
9
+ ...(await importOriginal()),
10
+ spawn: mocks.spawn,
11
+ }));
12
+
13
+ import { main, runSpawn } from "./run";
14
+
15
+ const createChild = () =>
16
+ Object.assign(new EventEmitter(), {
17
+ kill: vi.fn(() => true),
18
+ });
19
+
20
+ describe("runSpawn", () => {
21
+ afterEach(() => {
22
+ vi.restoreAllMocks();
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ it("forwards termination signals to the child", async () => {
27
+ const child = createChild();
28
+ mocks.spawn.mockReturnValue(child);
29
+ const existingListeners = new Set(process.listeners("SIGTERM"));
30
+ const initialListeners = process.listenerCount("SIGTERM");
31
+ const result = runSpawn("assistant-ui", ["create"]);
32
+ const signalHandler = process
33
+ .listeners("SIGTERM")
34
+ .find((listener) => !existingListeners.has(listener));
35
+
36
+ expect(signalHandler).toBeDefined();
37
+ signalHandler?.();
38
+ expect(child.kill).toHaveBeenCalledWith("SIGTERM");
39
+
40
+ child.emit("close", 0, null);
41
+ await expect(result).rejects.toMatchObject({
42
+ signal: "SIGTERM",
43
+ forwarded: true,
44
+ });
45
+ expect(process.listenerCount("SIGTERM")).toBe(initialListeners);
46
+ });
47
+
48
+ it("force-stops the child after a repeated termination signal", async () => {
49
+ const child = createChild();
50
+ mocks.spawn.mockReturnValue(child);
51
+ const existingListeners = new Set(process.listeners("SIGTERM"));
52
+ const initialListeners = process.listenerCount("SIGTERM");
53
+ const result = runSpawn("assistant-ui", ["create"]);
54
+ const signalHandler = process
55
+ .listeners("SIGTERM")
56
+ .find((listener) => !existingListeners.has(listener));
57
+
58
+ expect(signalHandler).toBeDefined();
59
+ signalHandler?.();
60
+ signalHandler?.();
61
+
62
+ expect(child.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
63
+ expect(child.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
64
+ await expect(result).rejects.toMatchObject({
65
+ signal: "SIGTERM",
66
+ forwarded: true,
67
+ });
68
+ expect(process.listenerCount("SIGTERM")).toBe(initialListeners);
69
+ });
70
+
71
+ it("distinguishes child crash signals from forwarded signals", async () => {
72
+ const child = createChild();
73
+ mocks.spawn.mockReturnValue(child);
74
+ const result = runSpawn("assistant-ui", ["create"]);
75
+
76
+ child.emit("close", null, "SIGSEGV");
77
+
78
+ await expect(result).rejects.toMatchObject({
79
+ signal: "SIGSEGV",
80
+ forwarded: false,
81
+ });
82
+ });
83
+
84
+ it("removes signal forwarding after normal completion", async () => {
85
+ const child = createChild();
86
+ mocks.spawn.mockReturnValue(child);
87
+ const initialSigintListeners = process.listenerCount("SIGINT");
88
+ const initialSigtermListeners = process.listenerCount("SIGTERM");
89
+ const result = runSpawn("assistant-ui", ["create"]);
90
+
91
+ child.emit("close", 0, null);
92
+ await expect(result).resolves.toBeUndefined();
93
+
94
+ expect(process.listenerCount("SIGINT")).toBe(initialSigintListeners);
95
+ expect(process.listenerCount("SIGTERM")).toBe(initialSigtermListeners);
96
+ });
97
+ });
98
+
99
+ describe("main", () => {
100
+ afterEach(() => {
101
+ vi.restoreAllMocks();
102
+ vi.clearAllMocks();
103
+ });
104
+
105
+ it("re-raises a signal forwarded to the child", async () => {
106
+ const child = createChild();
107
+ mocks.spawn.mockReturnValue(child);
108
+ const existingListeners = new Set(process.listeners("SIGTERM"));
109
+ const kill = vi.spyOn(process, "kill").mockReturnValue(true);
110
+ const removeAllListeners = vi
111
+ .spyOn(process, "removeAllListeners")
112
+ .mockReturnValue(process);
113
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
114
+ throw new Error("process.exit");
115
+ });
116
+ const result = main();
117
+
118
+ await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledOnce());
119
+ const signalHandler = process
120
+ .listeners("SIGTERM")
121
+ .find((listener) => !existingListeners.has(listener));
122
+ expect(signalHandler).toBeDefined();
123
+ signalHandler?.();
124
+ child.emit("close", null, "SIGTERM");
125
+
126
+ await expect(result).rejects.toThrow("process.exit");
127
+ expect(removeAllListeners).toHaveBeenCalledWith("SIGTERM");
128
+ expect(kill).toHaveBeenCalledWith(process.pid, "SIGTERM");
129
+ expect(removeAllListeners.mock.invocationCallOrder[0]).toBeLessThan(
130
+ kill.mock.invocationCallOrder[0]!,
131
+ );
132
+ expect(exit).toHaveBeenCalledWith(143);
133
+ });
134
+
135
+ it("does not re-raise a signal received only by the child", async () => {
136
+ const child = createChild();
137
+ mocks.spawn.mockReturnValue(child);
138
+ const kill = vi.spyOn(process, "kill").mockReturnValue(true);
139
+ const removeAllListeners = vi
140
+ .spyOn(process, "removeAllListeners")
141
+ .mockReturnValue(process);
142
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
143
+ throw new Error("process.exit");
144
+ });
145
+ const result = main();
146
+
147
+ await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledOnce());
148
+ child.emit("close", null, "SIGSEGV");
149
+
150
+ await expect(result).rejects.toThrow("process.exit");
151
+ expect(removeAllListeners).not.toHaveBeenCalled();
152
+ expect(kill).not.toHaveBeenCalled();
153
+ expect(exit).toHaveBeenCalledWith(139);
154
+ });
155
+ });
package/src/run.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "cross-spawn";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { createRequire } from "node:module";
4
+ import { constants } from "node:os";
4
5
  import path from "node:path";
5
6
 
6
7
  const require = createRequire(import.meta.url);
@@ -14,14 +15,58 @@ class SpawnExitError extends Error {
14
15
  }
15
16
  }
16
17
 
17
- function runSpawn(command: string, args: string[]): Promise<void> {
18
+ class SpawnSignalError extends Error {
19
+ signal: NodeJS.Signals;
20
+ forwarded: boolean;
21
+
22
+ constructor(signal: NodeJS.Signals, forwarded: boolean) {
23
+ super(`Process terminated by ${signal}`);
24
+ this.signal = signal;
25
+ this.forwarded = forwarded;
26
+ }
27
+ }
28
+
29
+ export function runSpawn(command: string, args: string[]): Promise<void> {
18
30
  return new Promise((resolve, reject) => {
19
31
  const child = spawn(command, args, {
20
32
  stdio: "inherit",
21
33
  });
34
+ let forwardedSignal: NodeJS.Signals | null = null;
35
+
36
+ const forwardSignal = (signal: NodeJS.Signals) => {
37
+ if (forwardedSignal !== null) {
38
+ child.kill("SIGKILL");
39
+ cleanup();
40
+ reject(new SpawnSignalError(signal, true));
41
+ return;
42
+ }
43
+ forwardedSignal = signal;
44
+ child.kill(signal);
45
+ };
46
+ const onSigint = () => forwardSignal("SIGINT");
47
+ const onSigterm = () => forwardSignal("SIGTERM");
48
+ const cleanup = () => {
49
+ process.off("SIGINT", onSigint);
50
+ process.off("SIGTERM", onSigterm);
51
+ };
52
+
53
+ process.on("SIGINT", onSigint);
54
+ process.on("SIGTERM", onSigterm);
22
55
 
23
- child.on("error", (error) => reject(error));
24
- child.on("close", (code) => {
56
+ child.on("error", (error) => {
57
+ cleanup();
58
+ reject(error);
59
+ });
60
+ child.on("close", (code, signal) => {
61
+ cleanup();
62
+ if (forwardedSignal !== null) {
63
+ reject(new SpawnSignalError(forwardedSignal, true));
64
+ return;
65
+ }
66
+ if (signal !== null) {
67
+ reject(new SpawnSignalError(signal, false));
68
+ return;
69
+ }
25
70
  if (code !== 0) {
26
71
  reject(new SpawnExitError(code || 1));
27
72
  } else {
@@ -61,6 +106,14 @@ export async function main(): Promise<void> {
61
106
 
62
107
  await runSpawn(process.execPath, [assistantUiBinPath, ...args]);
63
108
  } catch (error) {
109
+ if (error instanceof SpawnSignalError) {
110
+ if (error.forwarded) {
111
+ process.removeAllListeners(error.signal);
112
+ process.kill(process.pid, error.signal);
113
+ }
114
+ process.exit(128 + (constants.signals[error.signal] ?? 0));
115
+ }
116
+
64
117
  if (error instanceof SpawnExitError) {
65
118
  process.exit(error.code);
66
119
  }