@plannotator/pi-extension 0.27.0 → 0.27.1

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.
@@ -8,7 +8,7 @@
8
8
  * (packages/shared/open-in-apps.ts, vendored into generated/).
9
9
  */
10
10
 
11
- import { execFile, execFileSync, spawn } from "node:child_process";
11
+ import { execFileSync, spawn } from "node:child_process";
12
12
  import { existsSync } from "node:fs";
13
13
  import { basename, dirname, join } from "node:path";
14
14
  import os from "node:os";
@@ -103,7 +103,26 @@ export function getAvailableOpenInApps(): Array<{
103
103
  });
104
104
  }
105
105
 
106
- /** Run execFile and surface ENOENT (app not found) as a friendly error. */
106
+ /**
107
+ * How long a launch may take to fail before we call it launched (ms). Mirrors
108
+ * the Bun runtime (packages/server/open-in.ts).
109
+ */
110
+ const LAUNCH_GRACE_MS = 2000;
111
+
112
+ /** Cap on captured stderr; the stream keeps draining beyond it. */
113
+ const LAUNCH_STDERR_CAP_BYTES = 8192;
114
+
115
+ /**
116
+ * Run an argv launcher and surface ENOENT (app not found) as a friendly error.
117
+ *
118
+ * Mirrors the Bun runtime's runArgv semantics exactly: the child is spawned
119
+ * DETACHED (its own process group) so an editor cold-started by a launcher CLI
120
+ * can never be taken down by a signal aimed at this server's group, the wait
121
+ * is BOUNDED so a launcher CLI that stays attached to the app it started
122
+ * cannot hold the HTTP request hostage, and stderr is drained from spawn time
123
+ * so a chatty child can never fill the pipe and deadlock inside the grace
124
+ * window.
125
+ */
107
126
  function run(
108
127
  cmd: string,
109
128
  args: string[],
@@ -111,20 +130,85 @@ function run(
111
130
  opts?: { cwd?: string },
112
131
  ): Promise<{ ok: true } | { ok: false; error: string }> {
113
132
  return new Promise((resolve) => {
114
- const proc = execFile(cmd, args, opts?.cwd ? { cwd: opts.cwd } : {}, (err) => {
115
- if (!err) {
116
- resolve({ ok: true });
133
+ const failure = (msg: string): { ok: false; error: string } =>
134
+ /ENOENT|not found/i.test(msg)
135
+ ? { ok: false, error: `${notFoundLabel} was not found on this system.` }
136
+ : { ok: false, error: msg };
137
+
138
+ let child: ReturnType<typeof spawn>;
139
+ try {
140
+ child = spawn(cmd, args, {
141
+ detached: true,
142
+ stdio: ["ignore", "ignore", "pipe"],
143
+ ...(opts?.cwd ? { cwd: opts.cwd } : {}),
144
+ });
145
+ } catch (err) {
146
+ resolve(failure(err instanceof Error ? err.message : String(err)));
147
+ return;
148
+ }
149
+
150
+ let stderr = "";
151
+ child.stderr?.on("data", (chunk: Buffer) => {
152
+ if (stderr.length < LAUNCH_STDERR_CAP_BYTES) stderr += chunk.toString();
153
+ });
154
+
155
+ let settled = false;
156
+ let exitInfo: { code: number | null; signal: NodeJS.Signals | null } | null =
157
+ null;
158
+ const finish = (result: { ok: true } | { ok: false; error: string }) => {
159
+ if (settled) return;
160
+ settled = true;
161
+ clearTimeout(deadline);
162
+ resolve(result);
163
+ };
164
+
165
+ const concludeExit = () => {
166
+ if (!exitInfo) return;
167
+ if (/not found|ENOENT/i.test(stderr)) {
168
+ finish({ ok: false, error: `${notFoundLabel} was not found on this system.` });
169
+ return;
170
+ }
171
+ const status = exitInfo.code ?? exitInfo.signal ?? "unknown";
172
+ finish({
173
+ ok: false,
174
+ error: `Failed to open ${notFoundLabel} (exit ${status})${stderr ? `: ${stderr.trim()}` : ""}`,
175
+ });
176
+ };
177
+
178
+ // Still running at the deadline: it launched. A failure observed just
179
+ // before the deadline still reports as a failure.
180
+ const deadline = setTimeout(() => {
181
+ if (exitInfo) concludeExit();
182
+ else finish({ ok: true });
183
+ }, LAUNCH_GRACE_MS);
184
+
185
+ child.once("error", (err) => {
186
+ finish(failure(err instanceof Error ? err.message : String(err)));
187
+ });
188
+
189
+ child.once("exit", (code, signal) => {
190
+ if (code === 0) {
191
+ finish({ ok: true });
117
192
  return;
118
193
  }
119
- const code = (err as NodeJS.ErrnoException).code;
120
- if (code === "ENOENT" || /ENOENT|not found/i.test(err.message)) {
121
- resolve({ ok: false, error: `${notFoundLabel} was not found on this system.` });
122
- } else {
123
- resolve({ ok: false, error: err.message });
194
+ exitInfo = { code, signal };
195
+ // Give the stderr pipe a beat to flush before reporting; "close" (all
196
+ // stdio ended) concludes immediately when it arrives first. close alone
197
+ // is not enough: a grandchild inheriting the pipe can hold it open.
198
+ setTimeout(concludeExit, 50);
199
+ });
200
+
201
+ child.once("close", (code, signal) => {
202
+ if (code === 0) {
203
+ finish({ ok: true });
204
+ return;
124
205
  }
206
+ exitInfo = { code, signal };
207
+ concludeExit();
125
208
  });
126
- // Detach: we don't care about the child's lifetime once launched.
127
- proc.unref?.();
209
+
210
+ // The launcher runs on its own; never keep this process alive for it.
211
+ child.unref();
128
212
  });
129
213
  }
130
214