slidemind 0.1.8 → 0.2.0

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.
@@ -9,8 +9,16 @@
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
11
 
12
+ import {
13
+ DIMENSION_PROBE_BYTES,
14
+ imageSizeFromBuffer,
15
+ svgSizeFromMarkup,
16
+ } from "./image-dimensions.mjs";
12
17
  import { describeRecolor } from "./recolor.mjs";
13
18
 
19
+ // Every image format an engine accepts, plus the alias spellings seen in the
20
+ // wild. Mirrors the add-in's `searchImage` set (lib/image-formats.ts there);
21
+ // `extFromMime`'s `image/*` fallback below still covers anything newer.
14
22
  const MIME_EXT = {
15
23
  "image/svg+xml": "svg",
16
24
  "image/png": "png",
@@ -18,6 +26,9 @@ const MIME_EXT = {
18
26
  "image/jpg": "jpg",
19
27
  "image/webp": "webp",
20
28
  "image/gif": "gif",
29
+ "image/bmp": "bmp",
30
+ "image/x-ms-bmp": "bmp",
31
+ "image/tiff": "tiff",
21
32
  };
22
33
 
23
34
  // Derive a usable file extension from an engine result's MIME type. The brand
@@ -42,8 +53,186 @@ export function extFromMime(mimeType) {
42
53
  return "bin";
43
54
  }
44
55
 
56
+ // Below this, a transfer finishes faster than a progress line is worth reading.
57
+ // Most brand icons and slide slices are well under it, so the common case stays
58
+ // silent.
59
+ const PROGRESS_MIN_BYTES = 2 * 1024 * 1024;
60
+ /** Emit at most one line per this much progress. */
61
+ const PROGRESS_STEP_BYTES = 4 * 1024 * 1024;
62
+
63
+ const mb = (n) => (n / (1024 * 1024)).toFixed(1);
64
+
65
+ // Progress reporter for a streamed transfer.
66
+ //
67
+ // Deliberately NOT an in-place bar: this CLI's convention (see
68
+ // `renderProgressPrinter` in bin/slidemind.mjs — "one stderr line per state
69
+ // change — works in plain logs and TTYs alike") is milestone lines, because the
70
+ // primary consumer is an agent reading piped stdout, where `\r` repainting is
71
+ // noise rather than motion. So: nothing for small files, and a line every few MB
72
+ // for large ones, on stderr so it never pollutes parseable stdout.
73
+ export function createTransferProgress(label, write = process.stderr.write.bind(process.stderr)) {
74
+ let nextAt = PROGRESS_STEP_BYTES;
75
+ let announced = false;
76
+ return (bytesWritten, total) => {
77
+ if (total != null && total < PROGRESS_MIN_BYTES) return;
78
+ if (bytesWritten < nextAt) return;
79
+ nextAt = bytesWritten + PROGRESS_STEP_BYTES;
80
+ if (total != null && total > 0) {
81
+ const pct = Math.min(99, Math.floor((bytesWritten / total) * 100));
82
+ write(` ${label}: ${mb(bytesWritten)}/${mb(total)} MB (${pct}%)…\n`);
83
+ } else {
84
+ // No content-length (a decoded/chunked stream): report absolute progress.
85
+ write(` ${label}: ${mb(bytesWritten)} MB…\n`);
86
+ }
87
+ announced = true;
88
+ return announced;
89
+ };
90
+ }
91
+
92
+ const ascii = (s) => [...s].map((c) => c.charCodeAt(0));
93
+
94
+ // Magic-number signatures for the image formats an engine ACCEPTS — the add-in's
95
+ // `searchImage` set (lib/image-formats.ts there, EngineFileExtensions.search_image
96
+ // in its API). An engine cannot hold a format it refuses at ingest, so this list
97
+ // is complete by construction; keep it in step with that set.
98
+ //
99
+ // `svg` is absent deliberately: it is XML with no signature, so it can only be
100
+ // trusted from its declared type.
101
+ const MAGIC_SIGNATURES = [
102
+ { ext: "png", parts: [[0, [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a]]] },
103
+ { ext: "jpg", parts: [[0, [0xff, 0xd8, 0xff]]] },
104
+ { ext: "gif", parts: [[0, ascii("GIF8")]] },
105
+ { ext: "bmp", parts: [[0, ascii("BM")]] },
106
+ // Endian-dependent: "II" + 42 little, "MM" + 42 big.
107
+ { ext: "tiff", parts: [[0, [...ascii("II"), 0x2a, 0x00]]] },
108
+ { ext: "tiff", parts: [[0, [...ascii("MM"), 0x00, 0x2a]]] },
109
+ // RIFF<4-byte size>WEBP — the format tag sits past the container header.
110
+ { ext: "webp", parts: [[0, ascii("RIFF")], [8, ascii("WEBP")]] },
111
+ ];
112
+
113
+ // Identify an image from its BYTES, ignoring any declared MIME type. Returns null
114
+ // when nothing matches (an SVG, or a format this system never ingests) so the
115
+ // caller keeps its MIME-derived guess rather than inventing one.
116
+ //
117
+ // Why sniff at all: the declared type is only as good as the upstream
118
+ // `Content-Type`, and it is demonstrably wrong in the wild — a 1024x1024 PNG in
119
+ // the corpus arrived as `data:image/jpeg`, so the CLI wrote PNG bytes into a
120
+ // `.jpg` file. A wrong extension is not cosmetic here: the asset is referenced
121
+ // from slide JSX as `<img src>` and the renderer picks its decoder from the file
122
+ // type, so a mislabelled asset can fail to render in the .pptx.
123
+ export function extFromBytes(buf) {
124
+ if (!buf || buf.length < 2) return null;
125
+ for (const { ext, parts } of MAGIC_SIGNATURES) {
126
+ const matched = parts.every(([offset, bytes]) => {
127
+ if (buf.length < offset + bytes.length) return false;
128
+ return bytes.every((b, i) => buf[offset + i] === b);
129
+ });
130
+ if (matched) return ext;
131
+ }
132
+ return null;
133
+ }
134
+
135
+ // The extension to write bytes under: what the bytes ARE beats what the wire
136
+ // claims. SVG is exempt — it is XML with no magic number, and the SVG path is
137
+ // already gated on `svgMarkup` being present.
138
+ export function resolveAssetExt(mimeType, buf) {
139
+ const declared = extFromMime(mimeType);
140
+ if (declared === "svg") return declared;
141
+ return extFromBytes(buf) ?? declared;
142
+ }
143
+
144
+ // Stream one result into assetsDir via the add-in's streaming route.
145
+ //
146
+ // Returns the same shape as `downloadOneResult`, or null when the paired add-in
147
+ // has no streaming route (404/501) so the caller can fall back to the capped
148
+ // ack path. A real failure (bad selection, dead source) throws.
149
+ async function streamOneResult({
150
+ client,
151
+ presId,
152
+ backendURL,
153
+ searchId,
154
+ assetsDir,
155
+ selection,
156
+ iconColor,
157
+ }) {
158
+ if (typeof client.engineDownloadToFile !== "function") return null;
159
+ // Write under a provisional name: the true extension is only knowable from the
160
+ // bytes, and buffering the image just to sniff its header would defeat the
161
+ // purpose of streaming.
162
+ const tempPath = path.join(assetsDir, `.engine-${searchId}-${selection}.part`);
163
+ let res;
164
+ try {
165
+ res = await client.engineDownloadToFile({
166
+ presId,
167
+ backendURL,
168
+ searchId,
169
+ selection,
170
+ destPath: tempPath,
171
+ iconColor: iconColor ?? null,
172
+ onProgress: createTransferProgress(`downloading result ${selection}`),
173
+ });
174
+ } catch (err) {
175
+ fs.rmSync(tempPath, { force: true });
176
+ // Route absent → this add-in predates streaming; let the caller fall back.
177
+ // A 404 that carries a machine code is the ROUTE talking (`unknown_search`
178
+ // for a stale selection), not Express saying "no such path" — retrying that
179
+ // on the capped path would just fail again, more slowly.
180
+ if ((err?.status === 404 && !err?.code) || err?.status === 501) return null;
181
+ throw err;
182
+ }
183
+
184
+ try {
185
+ if (res?.svgMarkup) {
186
+ // SVG arrives inline, not as a stream.
187
+ fs.rmSync(tempPath, { force: true });
188
+ const filename = `engine-${searchId}-${selection}.svg`;
189
+ const target = path.join(assetsDir, filename);
190
+ fs.writeFileSync(target, res.svgMarkup, "utf8");
191
+ return {
192
+ selection,
193
+ path: target,
194
+ reference: `./assets/${filename}`,
195
+ mimeType: res.mimeType ?? "image/svg+xml",
196
+ recolor: describeRecolor(res.svgMarkup, res.mimeType, iconColor ?? null),
197
+ size: svgSizeFromMarkup(res.svgMarkup),
198
+ };
199
+ }
200
+
201
+ if (!res?.bytesWritten) {
202
+ throw new Error("stream produced no bytes");
203
+ }
204
+ // Read only the leading header block, not the whole file: enough to sniff
205
+ // the format AND to reach a JPEG's SOF segment past its EXIF/ICC blocks.
206
+ const fd = fs.openSync(tempPath, "r");
207
+ const probeBytes = Math.min(DIMENSION_PROBE_BYTES, res.bytesWritten);
208
+ const header = Buffer.alloc(probeBytes);
209
+ let read = 0;
210
+ try {
211
+ read = fs.readSync(fd, header, 0, probeBytes, 0);
212
+ } finally {
213
+ fs.closeSync(fd);
214
+ }
215
+ const probe = header.subarray(0, read);
216
+ const ext = resolveAssetExt(res.mimeType, probe);
217
+ const filename = `engine-${searchId}-${selection}.${ext}`;
218
+ const target = path.join(assetsDir, filename);
219
+ fs.renameSync(tempPath, target);
220
+ return {
221
+ selection,
222
+ path: target,
223
+ reference: `./assets/${filename}`,
224
+ mimeType: res.mimeType ?? null,
225
+ recolor: describeRecolor(null, res.mimeType, iconColor ?? null),
226
+ size: imageSizeFromBuffer(probe),
227
+ };
228
+ } catch (err) {
229
+ fs.rmSync(tempPath, { force: true });
230
+ throw err;
231
+ }
232
+ }
233
+
45
234
  // Download one chosen engine result via the bridge and write it into assetsDir.
46
- // Returns { selection, path, reference, mimeType, recolor }. Throws on failure
235
+ // Returns { selection, path, reference, mimeType, recolor, size }. Throws on failure
47
236
  // so callers can Promise.allSettled a batch without one bad selection sinking
48
237
  // the rest.
49
238
  export async function downloadOneResult({
@@ -55,6 +244,23 @@ export async function downloadOneResult({
55
244
  selection,
56
245
  iconColor,
57
246
  }) {
247
+ // Preferred path: stream via the add-in server, which is unbounded in image
248
+ // size. The extension can only be decided once bytes exist, so stream to a
249
+ // temp name first and rename — cheaper than buffering a 100 MB image to sniff
250
+ // its header.
251
+ const streamed = await streamOneResult({
252
+ client,
253
+ presId,
254
+ backendURL,
255
+ searchId,
256
+ assetsDir,
257
+ selection,
258
+ iconColor,
259
+ });
260
+ if (streamed) return streamed;
261
+
262
+ // Fallback for an add-in that predates the streaming route: bytes ride the
263
+ // pane's Socket.IO ack, capped by its 10 MB frame limit.
58
264
  const res = await client.engineDownload({
59
265
  presId,
60
266
  backendURL,
@@ -63,16 +269,28 @@ export async function downloadOneResult({
63
269
  iconColor: iconColor ?? null,
64
270
  });
65
271
  if (!res?.ok) {
66
- throw new Error(res?.error ?? "download failed");
272
+ // `payload_too_large` is the add-in refusing an ack the relay's Socket.IO
273
+ // frame cap would otherwise DROP — which previously surfaced as an
274
+ // unexplained "task pane did not respond" timeout. Its message already
275
+ // carries the sizes, so pass it through as the cause rather than restating.
276
+ const err = new Error(res?.error ?? "download failed");
277
+ if (res?.code) err.code = res.code;
278
+ throw err;
67
279
  }
68
- const ext = extFromMime(res.mimeType);
280
+ // Decode first so the extension can be decided by the BYTES, not by a
281
+ // Content-Type the engine inherited from upstream and may have wrong.
282
+ const bytes =
283
+ typeof res.base64 === "string" && res.base64.length > 0
284
+ ? Buffer.from(res.base64, "base64")
285
+ : null;
286
+ const ext = resolveAssetExt(res.mimeType, bytes);
69
287
  const filename = `engine-${searchId}-${selection}.${ext}`;
70
288
  const target = path.join(assetsDir, filename);
71
289
  if (res.svgMarkup && ext === "svg") {
72
290
  // The (possibly recoloured) SVG markup is the canonical asset.
73
291
  fs.writeFileSync(target, res.svgMarkup, "utf8");
74
- } else if (typeof res.base64 === "string" && res.base64.length > 0) {
75
- fs.writeFileSync(target, Buffer.from(res.base64, "base64"));
292
+ } else if (bytes) {
293
+ fs.writeFileSync(target, bytes);
76
294
  } else if (res.svgMarkup) {
77
295
  // SVG bytes arrived only as markup (mimeType lacked an svg hint).
78
296
  fs.writeFileSync(target, res.svgMarkup, "utf8");
@@ -85,5 +303,11 @@ export async function downloadOneResult({
85
303
  reference: `./assets/${filename}`,
86
304
  mimeType: res.mimeType,
87
305
  recolor: describeRecolor(res.svgMarkup ?? null, res.mimeType, iconColor ?? null),
306
+ // Measured from the asset actually written: SVG user units for markup,
307
+ // pixels for a raster. Null when the format carries no readable header.
308
+ size:
309
+ ext === "svg"
310
+ ? svgSizeFromMarkup(res.svgMarkup ?? null)
311
+ : imageSizeFromBuffer(bytes),
88
312
  };
89
313
  }
@@ -0,0 +1,181 @@
1
+ // Intrinsic pixel dimensions of an image, read from its own header bytes.
2
+ //
3
+ // Why here and not from the add-in: the engine wire carries a MIME type and the
4
+ // bytes, never a size, and both transfer paths (the streaming route and the
5
+ // capped ack fallback) end with the file already on disk. Measuring locally
6
+ // therefore works against every add-in version with no wire change, and it
7
+ // measures the asset the deck will actually reference rather than a thumbnail.
8
+ //
9
+ // Consumers: `slidemind download-result` reports the size so the agent can size
10
+ // an `<img>` box to the real aspect ratio instead of guessing one.
11
+
12
+ // Scan far enough into a JPEG to reach its first SOF segment past the EXIF/ICC
13
+ // blocks that routinely occupy the first few dozen KB.
14
+ export const DIMENSION_PROBE_BYTES = 128 * 1024;
15
+
16
+ const ascii = (s) => [...s].map((c) => c.charCodeAt(0));
17
+
18
+ const startsWith = (buf, offset, bytes) =>
19
+ buf.length >= offset + bytes.length && bytes.every((b, i) => buf[offset + i] === b);
20
+
21
+ function pngSize(buf) {
22
+ // 8-byte signature, then the IHDR chunk: length(4) type(4) width(4) height(4).
23
+ if (!startsWith(buf, 12, ascii("IHDR"))) return null;
24
+ if (buf.length < 24) return null;
25
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
26
+ }
27
+
28
+ function gifSize(buf) {
29
+ if (buf.length < 10) return null;
30
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
31
+ }
32
+
33
+ function bmpSize(buf) {
34
+ if (buf.length < 26) return null;
35
+ const headerSize = buf.readUInt32LE(14);
36
+ // BITMAPCOREHEADER is 16-bit; every later DIB header is 32-bit signed, with a
37
+ // negative height meaning a top-down bitmap.
38
+ if (headerSize === 12) {
39
+ return { width: buf.readUInt16LE(18), height: buf.readUInt16LE(20) };
40
+ }
41
+ if (buf.length < 27) return null;
42
+ return { width: Math.abs(buf.readInt32LE(18)), height: Math.abs(buf.readInt32LE(22)) };
43
+ }
44
+
45
+ function webpSize(buf) {
46
+ // RIFF<size>WEBP, then one of three chunk flavours.
47
+ if (buf.length < 30) return null;
48
+ const chunk = String.fromCharCode(buf[12], buf[13], buf[14], buf[15]);
49
+ if (chunk === "VP8 ") {
50
+ // Lossy: 3-byte frame tag, 3-byte sync code, then 14-bit width/height.
51
+ return {
52
+ width: buf.readUInt16LE(26) & 0x3fff,
53
+ height: buf.readUInt16LE(28) & 0x3fff,
54
+ };
55
+ }
56
+ if (chunk === "VP8L") {
57
+ // Lossless: signature byte 0x2f, then 14 bits width-1 and 14 bits height-1.
58
+ if (buf[20] !== 0x2f) return null;
59
+ const bits = buf.readUInt32LE(21);
60
+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
61
+ }
62
+ if (chunk === "VP8X") {
63
+ // Extended: canvas size as two 24-bit little-endian values, minus one.
64
+ return {
65
+ width: buf.readUIntLE(24, 3) + 1,
66
+ height: buf.readUIntLE(27, 3) + 1,
67
+ };
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // Markers that carry no payload and so must not be length-skipped.
73
+ const JPEG_STANDALONE = new Set([0x01, 0xd8, 0xd9, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7]);
74
+ // Every Start-Of-Frame flavour (baseline, progressive, lossless, …). The three
75
+ // holes are DHT (0xc4), JPG extension (0xc8) and DAC (0xcc), which are not SOFs.
76
+ const isJpegSof = (m) => m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc;
77
+
78
+ function jpegSize(buf) {
79
+ let pos = 2; // past SOI
80
+ while (pos + 3 < buf.length) {
81
+ if (buf[pos] !== 0xff) {
82
+ // Resync: fill bytes (0xff) and the odd stray byte are legal between
83
+ // segments; anything else means the header is not parseable.
84
+ pos++;
85
+ continue;
86
+ }
87
+ let marker = buf[pos + 1];
88
+ // Any number of 0xff fill bytes may precede the marker itself.
89
+ let markerPos = pos + 1;
90
+ while (marker === 0xff && markerPos + 1 < buf.length) {
91
+ marker = buf[++markerPos];
92
+ }
93
+ if (JPEG_STANDALONE.has(marker)) {
94
+ pos = markerPos + 1;
95
+ continue;
96
+ }
97
+ if (markerPos + 3 >= buf.length) return null;
98
+ const length = buf.readUInt16BE(markerPos + 1);
99
+ if (isJpegSof(marker)) {
100
+ // SOF payload: precision(1) height(2) width(2).
101
+ if (markerPos + 7 >= buf.length) return null;
102
+ return {
103
+ height: buf.readUInt16BE(markerPos + 4),
104
+ width: buf.readUInt16BE(markerPos + 6),
105
+ };
106
+ }
107
+ if (length < 2) return null;
108
+ pos = markerPos + 1 + length;
109
+ }
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * Intrinsic pixel size of a raster image, read from its header bytes.
115
+ *
116
+ * Takes the same "the bytes beat the declared type" stance as `extFromBytes`
117
+ * (engine-download.mjs): the format is sniffed, never taken from a MIME type.
118
+ * Returns null for anything unrecognised (TIFF, a truncated header, an SVG) so
119
+ * the caller reports "unknown" rather than a fabricated size.
120
+ */
121
+ export function imageSizeFromBuffer(buf) {
122
+ if (!buf || buf.length < 16) return null;
123
+ let size = null;
124
+ if (startsWith(buf, 0, [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a])) size = pngSize(buf);
125
+ else if (startsWith(buf, 0, [0xff, 0xd8, 0xff])) size = jpegSize(buf);
126
+ else if (startsWith(buf, 0, ascii("GIF8"))) size = gifSize(buf);
127
+ else if (startsWith(buf, 0, ascii("BM"))) size = bmpSize(buf);
128
+ else if (startsWith(buf, 0, ascii("RIFF")) && startsWith(buf, 8, ascii("WEBP"))) size = webpSize(buf);
129
+ if (!size) return null;
130
+ if (!Number.isFinite(size.width) || !Number.isFinite(size.height)) return null;
131
+ if (size.width <= 0 || size.height <= 0) return null;
132
+ return size;
133
+ }
134
+
135
+ /**
136
+ * Intrinsic size of an SVG, in its own user units.
137
+ *
138
+ * `width`/`height` win when both are plain lengths; otherwise the viewBox
139
+ * supplies the aspect ratio (the common case for icon sets, which size
140
+ * themselves from CSS and carry only a viewBox). Percentage widths are ignored
141
+ * for the same reason — they describe the container, not the graphic.
142
+ */
143
+ export function svgSizeFromMarkup(markup) {
144
+ if (typeof markup !== "string" || markup.length === 0) return null;
145
+ // Only the opening <svg …> tag may contribute; a nested <symbol width=…>
146
+ // further down must not be mistaken for the root size.
147
+ const openTag = /<svg\b[^>]*>/i.exec(markup)?.[0];
148
+ if (!openTag) return null;
149
+
150
+ const length = (name) => {
151
+ const m = new RegExp(`\\b${name}\\s*=\\s*["']?\\s*([\\d.]+)\\s*([a-z%]*)`, "i").exec(openTag);
152
+ if (!m) return null;
153
+ // Only unitless user units and `px` are a pixel count. `%` sizes the
154
+ // container, not the graphic, and physical units (mm/pt/in) would need a DPI
155
+ // — both fall through to the viewBox, which always states the true ratio.
156
+ const unit = m[2].toLowerCase();
157
+ if (unit !== "" && unit !== "px") return null;
158
+ const n = Number.parseFloat(m[1]);
159
+ return Number.isFinite(n) && n > 0 ? n : null;
160
+ };
161
+
162
+ const width = length("width");
163
+ const height = length("height");
164
+ if (width && height) return { width, height };
165
+
166
+ const viewBox = /\bviewBox\s*=\s*["']?\s*([-\d.\s,]+)/i.exec(openTag);
167
+ if (viewBox) {
168
+ const parts = viewBox[1].trim().split(/[\s,]+/).map(Number.parseFloat);
169
+ if (parts.length === 4 && parts.every(Number.isFinite) && parts[2] > 0 && parts[3] > 0) {
170
+ return { width: width ?? parts[2], height: height ?? parts[3] };
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+
176
+ /** `1200×800` for display, or null when the size could not be read. */
177
+ export function formatSize(size) {
178
+ if (!size) return null;
179
+ const round = (n) => (Number.isInteger(n) ? n : Math.round(n * 100) / 100);
180
+ return `${round(size.width)}×${round(size.height)}`;
181
+ }
@@ -945,31 +945,61 @@ function openerChain(target) {
945
945
  return [{ cmd: "xdg-open", args: [target] }];
946
946
  }
947
947
 
948
+ // How long to give a platform opener to exit before assuming it handed the URL
949
+ // off successfully. `open`/`xdg-open`/`start` all exit within milliseconds; an
950
+ // opener still running after this window is treated as launched, not failed.
951
+ const OPENER_EXIT_GRACE_MS = 2000;
952
+
948
953
  // Open a local file path OR an http URL in the user's default browser, trying
949
- // each platform opener in turn. This is fire-and-forget and must NEVER crash:
950
- // if the opener binary is missing (e.g. a headless Linux box with no xdg-open)
951
- // the spawned child would otherwise emit an unhandled 'error' and take down the
952
- // whole CLI. We degrade gracefully — on exhausting the chain we just print the
953
- // target for the user to open manually.
954
- export function openInBrowser(target) {
954
+ // each platform opener in turn. Must NEVER crash: if the opener binary is
955
+ // missing (e.g. a headless Linux box with no xdg-open) the spawned child would
956
+ // otherwise emit an unhandled 'error' and take down the whole CLI.
957
+ //
958
+ // Resolves `true` when an opener plausibly launched (exit 0, or still running
959
+ // after the grace window), `false` when the whole chain failed — missing
960
+ // binaries AND nonzero exits both advance the chain, so a present-but-broken
961
+ // `xdg-open` (headless box, no browser) is reported as a failure instead of
962
+ // silently swallowed. Never rejects. On `false` the target is printed for the
963
+ // user to open manually unless `quiet` — callers with their own messaging (the
964
+ // review/plan gates) pass quiet:true and speak for themselves.
965
+ export function openInBrowser(target, { quiet = false } = {}) {
955
966
  const chain = openerChain(target);
956
- const tryAt = (i) => {
957
- if (i >= chain.length) {
958
- process.stderr.write(`couldn't open a browser — open ${target} manually\n`);
959
- return;
960
- }
961
- const { cmd, args } = chain[i];
962
- let child;
963
- try {
964
- child = spawn(cmd, args, { stdio: "ignore", detached: true });
965
- } catch {
966
- tryAt(i + 1);
967
- return;
968
- }
969
- child.on("error", () => tryAt(i + 1));
970
- child.unref();
971
- };
972
- tryAt(0);
967
+ return new Promise((resolve) => {
968
+ const tryAt = (i) => {
969
+ if (i >= chain.length) {
970
+ if (!quiet) {
971
+ process.stderr.write(`couldn't open a browser — open ${target} manually\n`);
972
+ }
973
+ resolve(false);
974
+ return;
975
+ }
976
+ const { cmd, args } = chain[i];
977
+ let child;
978
+ try {
979
+ child = spawn(cmd, args, { stdio: "ignore", detached: true });
980
+ } catch {
981
+ tryAt(i + 1);
982
+ return;
983
+ }
984
+ let settled = false;
985
+ const settle = (ok) => {
986
+ if (settled) return;
987
+ settled = true;
988
+ clearTimeout(timer);
989
+ if (ok) resolve(true);
990
+ else tryAt(i + 1);
991
+ };
992
+ // Still running after the grace window ⇒ it took the URL; don't hold the
993
+ // caller hostage to an opener that lingers. unref'd so it can't keep the
994
+ // process alive either way.
995
+ const timer = setTimeout(() => settle(true), OPENER_EXIT_GRACE_MS);
996
+ if (timer.unref) timer.unref();
997
+ child.on("error", () => settle(false));
998
+ child.on("exit", (code) => settle(code === 0));
999
+ child.unref();
1000
+ };
1001
+ tryAt(0);
1002
+ });
973
1003
  }
974
1004
 
975
1005
  function parseArgs(argv) {