slidemind 0.3.12 → 0.3.14

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.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +86 -21
  4. package/README.md +14 -17
  5. package/bin/slidemind.mjs +65 -4969
  6. package/package.json +1 -1
  7. package/skills/create-slides/SKILL.md +113 -74
  8. package/skills/search/SKILL.md +25 -7
  9. package/src/bridge/build-and-push.mjs +108 -15
  10. package/src/bridge/bundle.mjs +69 -0
  11. package/src/bridge/capture.mjs +71 -8
  12. package/src/bridge/client/brands.mjs +622 -0
  13. package/src/bridge/client/engine-routing.mjs +237 -0
  14. package/src/bridge/client/engine-search.mjs +310 -0
  15. package/src/bridge/client/engine-slides.mjs +435 -0
  16. package/src/bridge/client/pane-ops.mjs +335 -0
  17. package/src/bridge/client/presentations.mjs +587 -0
  18. package/src/bridge/client/render.mjs +317 -0
  19. package/src/bridge/client/transport.mjs +695 -0
  20. package/src/bridge/client.mjs +80 -3452
  21. package/src/bridge/config.mjs +1 -1
  22. package/src/bridge/engine-download.mjs +15 -2
  23. package/src/builders/build.mjs +16 -669
  24. package/src/builders/draft-check.mjs +376 -0
  25. package/src/builders/lint.mjs +38 -2
  26. package/src/builders/preview.html +799 -0
  27. package/src/builders/review.mjs +23 -4
  28. package/src/builders/visual-families.mjs +6 -8
  29. package/src/cli/common.mjs +179 -0
  30. package/src/cli/concepts.mjs +310 -0
  31. package/src/cli/connection.mjs +147 -0
  32. package/src/cli/deck.mjs +608 -0
  33. package/src/cli/designs.mjs +113 -0
  34. package/src/cli/diagnose.mjs +141 -0
  35. package/src/cli/download-bundle.mjs +144 -0
  36. package/src/cli/engine-helpers.mjs +429 -0
  37. package/src/cli/engine-results.mjs +320 -0
  38. package/src/cli/help.mjs +201 -0
  39. package/src/cli/install.mjs +257 -0
  40. package/src/cli/read.mjs +120 -0
  41. package/src/cli/ready.mjs +805 -0
  42. package/src/cli/search.mjs +608 -0
  43. package/src/cli/session.mjs +177 -0
  44. package/src/cli/setup.mjs +282 -0
  45. package/src/cli/slide-reuse.mjs +334 -0
  46. package/src/cli/status.mjs +118 -0
  47. package/src/cli/workspace.mjs +302 -0
  48. package/src/installers/codex.mjs +1 -1
  49. package/src/update-check.mjs +231 -0
@@ -0,0 +1,622 @@
1
+ // Brand routes: design bundles, brand listing, concept images, image generation,
2
+ // convert-pptx, and the engine catalog.
3
+
4
+ import { randomUUID } from "node:crypto";
5
+ import path from "node:path";
6
+ import { deriveEnvLabel } from "../config.mjs";
7
+ import { resolveBackendForPres } from "./presentations.mjs";
8
+ import { listTimeoutMs, engineTimeoutMs, withTransientRetry, sessionsInScope, sessionForBackend, bridgeFetch, backendCapabilities, backendSupportsBrandRoutes, backendSupportsConceptImages, backendSupportsImageGeneration, backendSupportsBrandsList, graphHeaders } from "./transport.mjs";
9
+
10
+ // GET the full design bundle (tokens, css, layouts, designRules, signed asset
11
+ // URLs, engines) for the presentation's active corporate design.
12
+ export async function getDesignBundle(
13
+ presId,
14
+ { backendURL = null, preferBrandId = null } = {},
15
+ ) {
16
+ if (!presId) throw new Error("getDesignBundle: presId is required");
17
+ const be =
18
+ backendURL || (await resolveBackendForPres(presId, { preferBrandId }));
19
+ return withTransientRetry(async () => {
20
+ const res = await bridgeFetch(
21
+ `/api/cli/presentations/${encodeURIComponent(presId)}/design-bundle`,
22
+ {},
23
+ be,
24
+ );
25
+ return res.json();
26
+ });
27
+ }
28
+
29
+ // GET the design bundle by brandId over the pane-free brand route. 404
30
+ // `unknown_brand` is definitive (unknown / cross-tenant / not granted to this
31
+ // user), so no transient retry. Requires the resolved backendURL — use
32
+ // getDesignBundleForBrandAnyBackend when it isn't known yet.
33
+ export async function getDesignBundleForBrand(brandId, { backendURL } = {}) {
34
+ if (!brandId) {
35
+ throw new Error("getDesignBundleForBrand: brandId is required");
36
+ }
37
+ if (!backendURL) {
38
+ throw new Error("getDesignBundleForBrand: backendURL is required");
39
+ }
40
+ const res = await bridgeFetch(
41
+ `/api/cli/brands/${encodeURIComponent(brandId)}/design-bundle`,
42
+ {},
43
+ backendURL,
44
+ );
45
+ return res.json();
46
+ }
47
+
48
+ // Resolve WHICH paired backend serves a brand by asking each one that
49
+ // advertises brand routes — a bound deck has no open window, so there is no
50
+ // registry entry to resolve a backend from. presFP/brandId is globally unique
51
+ // across deployments (the bundle-cache contract), so at most one backend
52
+ // answers 2xx. Returns { wire, backendURL }; throws a typed
53
+ // `brand_route_unavailable` when no backend serves the brand (old add-ins,
54
+ // unknown brand, missing grant) so callers fall back to the pane flow.
55
+ export async function getDesignBundleForBrandAnyBackend(brandId) {
56
+ if (!brandId) {
57
+ throw new Error("getDesignBundleForBrandAnyBackend: brandId is required");
58
+ }
59
+ const sessions = sessionsInScope();
60
+ const failures = [];
61
+ for (const s of sessions) {
62
+ try {
63
+ if (!(await backendSupportsBrandRoutes(s.backendURL))) {
64
+ failures.push({ env: deriveEnvLabel(s.backendURL), reason: "no brand routes" });
65
+ continue;
66
+ }
67
+ const wire = await getDesignBundleForBrand(brandId, {
68
+ backendURL: s.backendURL,
69
+ });
70
+ return { wire, backendURL: s.backendURL };
71
+ } catch (err) {
72
+ failures.push({
73
+ env: deriveEnvLabel(s.backendURL),
74
+ reason: err instanceof Error ? err.message : String(err),
75
+ });
76
+ }
77
+ }
78
+ const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
79
+ const err = new Error(
80
+ `no paired backend serves design '${brandId}' over the pane-free brand ` +
81
+ `route${detail ? ` (${detail})` : ""}.`,
82
+ );
83
+ err.code = "brand_route_unavailable";
84
+ throw err;
85
+ }
86
+
87
+ // The corporate designs this user may build against, straight off the CLI
88
+ // session — no open window, no pane. One entry per design: `{ presFP, name,
89
+ // env }`, where presFP IS the brandId every pane-free route is keyed by. Only
90
+ // backends advertising `brandsList` are asked (older ones have no such route);
91
+ // a backend that fails is skipped rather than failing the whole answer, so one
92
+ // unreachable environment can't hide another's designs.
93
+ export async function listBrands() {
94
+ const out = [];
95
+ for (const s of sessionsInScope()) {
96
+ if (!(await backendSupportsBrandsList(s.backendURL))) continue;
97
+ const env = deriveEnvLabel(s.backendURL);
98
+ try {
99
+ const res = await bridgeFetch(
100
+ "/api/cli/brands",
101
+ { timeoutMs: listTimeoutMs() },
102
+ s.backendURL,
103
+ );
104
+ const data = (await res.json()) ?? {};
105
+ const arr = Array.isArray(data.brands) ? data.brands : [];
106
+ for (const b of arr) {
107
+ // The wire may name the design by either of its two equivalent keys
108
+ // (presFP on the bundle contract, brandId on the routes) — normalize to
109
+ // the one the deck ref stores.
110
+ const presFP = b?.presFP ?? b?.brandId ?? b?.id ?? null;
111
+ if (!presFP) continue;
112
+ out.push({
113
+ presFP,
114
+ name: b?.name ?? b?.designName ?? null,
115
+ env,
116
+ backendURL: s.backendURL,
117
+ });
118
+ }
119
+ } catch {
120
+ /* unreachable / not granted — that environment simply contributes none */
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+
126
+ // Concept-image generation is a heavy provider call (per-image cap ~180s
127
+ // server-side). Budget just over that so a typed server error wins over a
128
+ // client timeout, but keep it below the relay's ~240s ingress cap — the CLI
129
+ // batches per-slide so no single request approaches that ceiling.
130
+ export function conceptTimeoutMs() {
131
+ return (
132
+ parseInt(process.env.SLIDEMIND_BRIDGE_CONCEPT_TIMEOUT_MS || "200000", 10) ||
133
+ 200000
134
+ );
135
+ }
136
+
137
+ export async function postConceptImages(
138
+ brandId,
139
+ { planMarkdown, slideNumbers, references, force },
140
+ backendURL,
141
+ ) {
142
+ const body = { planMarkdown };
143
+ // `--force`: the server keeps a per-plan mockup cache; an unchanged plan
144
+ // would otherwise hand back last time's images no matter what changed
145
+ // server-side (prompt, chrome, model).
146
+ if (force) body.force = true;
147
+ if (Array.isArray(slideNumbers) && slideNumbers.length > 0) {
148
+ body.slideNumbers = slideNumbers;
149
+ }
150
+ // The user's own images, inline: `{ role: "concept" | "style", image:
151
+ // <data URI>, slides?: number[], name? }`. A concept replaces the generated
152
+ // mockup for its slides; a style reference leads the generation.
153
+ if (Array.isArray(references) && references.length > 0) {
154
+ body.references = references;
155
+ }
156
+ const res = await bridgeFetch(
157
+ `/api/cli/brands/${encodeURIComponent(brandId)}/concept-images`,
158
+ {
159
+ method: "POST",
160
+ headers: { "content-type": "application/json" },
161
+ body: JSON.stringify(body),
162
+ timeoutMs: conceptTimeoutMs(),
163
+ },
164
+ backendURL,
165
+ );
166
+ const data = (await res.json()) ?? {};
167
+ // Tag the response with the backend that answered so a per-slide batch can
168
+ // pin subsequent calls to the same one (routing state only — never printed).
169
+ return { ...data, backendURL };
170
+ }
171
+
172
+ // Generate concept mockup images for a brand over the pane-free brand route.
173
+ // Body `{ planMarkdown, slideNumbers?, references? }`; the optional `slideNumbers`
174
+ // filters which storyboard beats are rendered (the CLI batches per-slide);
175
+ // `references` carries the user's own concept/style images inline. Returns the
176
+ // server's `{ ok, images, slideCount }` augmented with the resolved
177
+ // `backendURL`. When `backendURL` is given it posts directly; otherwise it
178
+ // fans out — like getDesignBundleForBrandAnyBackend, presFP/brandId is globally
179
+ // unique so at most one paired backend answers 2xx. Throws a typed
180
+ // `concept_route_unavailable` when no backend serves the brand (old add-ins,
181
+ // unknown brand, missing grant) so callers can degrade to "continue without".
182
+ export async function generateConceptImagesForBrand(
183
+ brandId,
184
+ {
185
+ planMarkdown,
186
+ slideNumbers = null,
187
+ references = null,
188
+ backendURL = null,
189
+ force = false,
190
+ } = {},
191
+ ) {
192
+ if (!brandId) {
193
+ throw new Error("generateConceptImagesForBrand: brandId is required");
194
+ }
195
+ if (typeof planMarkdown !== "string" || !planMarkdown.trim()) {
196
+ throw new Error("generateConceptImagesForBrand: planMarkdown is required");
197
+ }
198
+ if (backendURL) {
199
+ return postConceptImages(
200
+ brandId,
201
+ { planMarkdown, slideNumbers, references, force },
202
+ backendURL,
203
+ );
204
+ }
205
+ const sessions = sessionsInScope();
206
+ const failures = [];
207
+ for (const s of sessions) {
208
+ try {
209
+ if (!(await backendSupportsConceptImages(s.backendURL))) {
210
+ failures.push({
211
+ env: deriveEnvLabel(s.backendURL),
212
+ reason: "no concept-images route",
213
+ });
214
+ continue;
215
+ }
216
+ return await postConceptImages(
217
+ brandId,
218
+ { planMarkdown, slideNumbers, references, force },
219
+ s.backendURL,
220
+ );
221
+ } catch (err) {
222
+ failures.push({
223
+ env: deriveEnvLabel(s.backendURL),
224
+ reason: err instanceof Error ? err.message : String(err),
225
+ });
226
+ }
227
+ }
228
+ const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
229
+ const err = new Error(
230
+ `no paired backend generates concept mockups for design '${brandId}'` +
231
+ `${detail ? ` (${detail})` : ""}.`,
232
+ );
233
+ err.code = "concept_route_unavailable";
234
+ throw err;
235
+ }
236
+
237
+ // Which connected backend generates concept mockups. One CAPABILITIES probe per
238
+ // session answers it — cached, no image generated — which is why `concepts` can
239
+ // go straight to its waves instead of spending a throwaway single-slide request
240
+ // to learn who answers. Returns the one backend to pin a per-slide batch to, or
241
+ // null when several environments qualify (developer machines only; each request
242
+ // then fans out as before). Throws the typed `concept_route_unavailable` when
243
+ // none does, so the caller degrades to "continue without".
244
+ export async function resolveConceptBackend() {
245
+ const sessions = sessionsInScope();
246
+ const capable = [];
247
+ const failures = [];
248
+ for (const s of sessions) {
249
+ if (await backendSupportsConceptImages(s.backendURL)) {
250
+ capable.push(s.backendURL);
251
+ } else {
252
+ failures.push(`${deriveEnvLabel(s.backendURL)}: no concept-images route`);
253
+ }
254
+ }
255
+ if (capable.length === 0) {
256
+ const detail = failures.join("; ");
257
+ const err = new Error(
258
+ "no paired backend generates concept mockups" +
259
+ `${detail ? ` (${detail})` : ""}.`,
260
+ );
261
+ err.code = "concept_route_unavailable";
262
+ throw err;
263
+ }
264
+ return capable.length === 1 ? capable[0] : null;
265
+ }
266
+
267
+ // gpt-image calls routinely take 60-120s; budget like the concept relay so a
268
+ // typed server error wins over a client timeout.
269
+ export function imagenTimeoutMs() {
270
+ return (
271
+ parseInt(process.env.SLIDEMIND_BRIDGE_IMAGEN_TIMEOUT_MS || "200000", 10) ||
272
+ 200000
273
+ );
274
+ }
275
+
276
+ export async function postGenerateImage(brandId, body, backendURL) {
277
+ const res = await bridgeFetch(
278
+ `/api/cli/brands/${encodeURIComponent(brandId)}/images`,
279
+ {
280
+ method: "POST",
281
+ headers: { "content-type": "application/json" },
282
+ body: JSON.stringify(body),
283
+ timeoutMs: imagenTimeoutMs(),
284
+ },
285
+ backendURL,
286
+ );
287
+ const data = (await res.json()) ?? {};
288
+ return { ...data, backendURL };
289
+ }
290
+
291
+ // Generate ONE regular image over the pane-free brand route
292
+ // (`slidemind imagen`). Body `{ prompt, size?, quality? }`;
293
+ // returns the server's `{ ok, image }` augmented with the
294
+ // resolved `backendURL`. Fan-out and degradation mirror
295
+ // generateConceptImagesForBrand: throws a typed `imagen_route_unavailable`
296
+ // when no paired backend serves the route (older add-ins, unknown brand,
297
+ // missing grant) so callers can print the one actionable fix.
298
+ export async function generateImageForBrand(
299
+ brandId,
300
+ { prompt, size = null, quality = null, backendURL = null } = {},
301
+ ) {
302
+ if (!brandId) {
303
+ throw new Error("generateImageForBrand: brandId is required");
304
+ }
305
+ if (typeof prompt !== "string" || !prompt.trim()) {
306
+ throw new Error("generateImageForBrand: prompt is required");
307
+ }
308
+ const body = { prompt };
309
+ if (size) body.size = size;
310
+ if (quality) body.quality = quality;
311
+ if (backendURL) {
312
+ return postGenerateImage(brandId, body, backendURL);
313
+ }
314
+ const sessions = sessionsInScope();
315
+ const failures = [];
316
+ for (const s of sessions) {
317
+ try {
318
+ if (!(await backendSupportsImageGeneration(s.backendURL))) {
319
+ failures.push({
320
+ env: deriveEnvLabel(s.backendURL),
321
+ reason: "no image-generation route (update the add-in)",
322
+ });
323
+ continue;
324
+ }
325
+ return await postGenerateImage(brandId, body, s.backendURL);
326
+ } catch (err) {
327
+ failures.push({
328
+ env: deriveEnvLabel(s.backendURL),
329
+ reason: err instanceof Error ? err.message : String(err),
330
+ });
331
+ }
332
+ }
333
+ const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
334
+ const err = new Error(
335
+ `no paired backend generates images for design '${brandId}'` +
336
+ `${detail ? ` (${detail})` : ""}.`,
337
+ );
338
+ err.code = "imagen_route_unavailable";
339
+ throw err;
340
+ }
341
+
342
+ // Deterministic PPTX→JSX conversion is a per-slide OOXML parse + a render pass
343
+ // per converted slide — a whole foreign deck runs in render-grade time, not
344
+ // list-grade. Budget like the render routes.
345
+ export function convertTimeoutMs() {
346
+ return (
347
+ parseInt(process.env.SLIDEMIND_BRIDGE_CONVERT_TIMEOUT_MS || "300000", 10) ||
348
+ 300000
349
+ );
350
+ }
351
+
352
+ // Minimal multipart/form-data body. The route takes an uploaded PPTX, and
353
+ // nodeRequest writes a Buffer verbatim — no fetch/FormData involved, so the
354
+ // envelope is assembled here (fields first, file last, boundary never appearing
355
+ // in the parts because it carries a random suffix).
356
+ export function multipartBody(fields, file) {
357
+ const boundary = `----slidemind${randomUUID().replace(/-/g, "")}`;
358
+ const chunks = [];
359
+ for (const [name, value] of Object.entries(fields)) {
360
+ if (value === null || value === undefined) continue;
361
+ chunks.push(
362
+ Buffer.from(
363
+ `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
364
+ ),
365
+ );
366
+ }
367
+ // The file name reaches this from a path on disk; a quote or a newline in it
368
+ // would break out of the Content-Disposition header and forge a part.
369
+ const safeFileName = String(file.fileName).replace(/[\r\n"\\]/g, "_");
370
+ chunks.push(
371
+ Buffer.from(
372
+ `--${boundary}\r\nContent-Disposition: form-data; name="${file.name}"; ` +
373
+ `filename="${safeFileName}"\r\nContent-Type: ${file.contentType}\r\n\r\n`,
374
+ ),
375
+ Buffer.from(file.bytes),
376
+ Buffer.from(`\r\n--${boundary}--\r\n`),
377
+ );
378
+ return {
379
+ body: Buffer.concat(chunks),
380
+ contentType: `multipart/form-data; boundary=${boundary}`,
381
+ };
382
+ }
383
+
384
+ // Whether a backend deterministically converts foreign PPTX slides to jsx
385
+ // (`POST /api/cli/brands/:brandId/convert-pptx`). Advertised only by add-ins
386
+ // running with the reverse-conversion flag on; everywhere else `slidemind
387
+ // adopt` folds untagged slides in as native .pptx exactly as before.
388
+ export async function backendSupportsConvertPptx(backendURL) {
389
+ const caps = await backendCapabilities(backendURL);
390
+ return caps?.convertPptx === true;
391
+ }
392
+
393
+ export async function postConvertPptx(brandId, { bytes, fileName, slides }, backendURL) {
394
+ const { body, contentType } = multipartBody(
395
+ {
396
+ // 1-based play-order slide numbers to convert. Omitted = the whole deck;
397
+ // `adopt` always names the untagged ones so tagged slides are not re-parsed.
398
+ slides: Array.isArray(slides) && slides.length > 0 ? slides.join(",") : null,
399
+ },
400
+ {
401
+ name: "file",
402
+ fileName: path.basename(fileName || "deck.pptx"),
403
+ contentType:
404
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
405
+ bytes,
406
+ },
407
+ );
408
+ const res = await bridgeFetch(
409
+ `/api/cli/brands/${encodeURIComponent(brandId)}/convert-pptx`,
410
+ {
411
+ method: "POST",
412
+ headers: { "content-type": contentType },
413
+ body,
414
+ timeoutMs: convertTimeoutMs(),
415
+ },
416
+ backendURL,
417
+ );
418
+ const data = (await res.json()) ?? {};
419
+ return { ...data, backendURL };
420
+ }
421
+
422
+ // Convert a FILE deck's foreign slides to converted-dialect jsx over the
423
+ // pane-free brand route (`slidemind adopt`). Body is multipart: the .pptx plus
424
+ // the 1-based slide numbers to convert. Returns the server's
425
+ // `{ slides, layouts, assets, sourceBaselines, conversionEvidence, skipped,
426
+ // warnings }` (add-in `ConvertPptxResponse`) augmented with the resolved
427
+ // `backendURL`. Identity and evidence are keyed by each slide's jsx `path`, not
428
+ // carried on the slide — `src/lib/adopt.mjs` `convertedByNumber()` is the one
429
+ // place that key shape is unfolded.
430
+ //
431
+ // LIVE decks are NOT convertible through this route in v1: converting an open
432
+ // presentation needs a slide export + tag read over the relay, and neither
433
+ // event exists (design D9) — the add-in path owns that flow.
434
+ //
435
+ // Fan-out and degradation mirror generateConceptImagesForBrand: throws a typed
436
+ // `convert_route_unavailable` when no paired backend serves the route (older
437
+ // add-ins, the reverse flag off, unknown brand, missing grant) so `adopt` falls
438
+ // back to the native fold instead of failing.
439
+ export async function convertPptxForBrand(
440
+ brandId,
441
+ { bytes, fileName = "deck.pptx", slides = null, backendURL = null } = {},
442
+ ) {
443
+ if (!brandId) throw new Error("convertPptxForBrand: brandId is required");
444
+ if (!bytes) throw new Error("convertPptxForBrand: bytes is required");
445
+ if (backendURL) {
446
+ return postConvertPptx(brandId, { bytes, fileName, slides }, backendURL);
447
+ }
448
+ const sessions = sessionsInScope();
449
+ const failures = [];
450
+ for (const s of sessions) {
451
+ try {
452
+ if (!(await backendSupportsConvertPptx(s.backendURL))) {
453
+ failures.push({
454
+ env: deriveEnvLabel(s.backendURL),
455
+ reason: "no convert-pptx route",
456
+ });
457
+ continue;
458
+ }
459
+ return await postConvertPptx(
460
+ brandId,
461
+ { bytes, fileName, slides },
462
+ s.backendURL,
463
+ );
464
+ } catch (err) {
465
+ failures.push({
466
+ env: deriveEnvLabel(s.backendURL),
467
+ reason: err instanceof Error ? err.message : String(err),
468
+ });
469
+ }
470
+ }
471
+ const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
472
+ const err = new Error(
473
+ `no paired backend converts foreign slides for design '${brandId}'` +
474
+ `${detail ? ` (${detail})` : ""}.`,
475
+ );
476
+ err.code = "convert_route_unavailable";
477
+ throw err;
478
+ }
479
+
480
+ // List the LIVE engine catalog (NOT the bundle's curated set) for the paired
481
+ // backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
482
+ // when several environments are paired this fans out and tags each engine with
483
+ // its short `env` label (never the backendURL — the no-URL rule). Filter by
484
+ // engine `type` (collection|search) and/or `subtype` (image|slide|template|
485
+ // content). Returns a flat `{ engines: [...], catalog }` like the single-backend
486
+ // route.
487
+ //
488
+ // `catalog` is the pane-free route's summary — `{totalEngines, totalScopes,
489
+ // filtered, emptyEngines}`, counted BEFORE the type/subtype filter — and it is
490
+ // what separates "this filter matched nothing" from "this deployment has no
491
+ // engines". Older backends (and the pane-relay path) send none; it stays null
492
+ // there and every caller falls back to its pre-summary wording.
493
+ export function readCatalogSummary(data) {
494
+ const c = data?.catalog;
495
+ if (!c || typeof c !== "object" || Array.isArray(c)) return null;
496
+ const num = (v) => (Number.isFinite(v) ? v : 0);
497
+ return {
498
+ totalEngines: num(c.totalEngines),
499
+ totalScopes: num(c.totalScopes),
500
+ filtered: Boolean(c.filtered),
501
+ emptyEngines: num(c.emptyEngines),
502
+ };
503
+ }
504
+
505
+ export async function listEnginesForBackend(backendURL, { type = null, subtype = null, signIn = true } = {}) {
506
+ return withTransientRetry(async () => {
507
+ const params = new URLSearchParams();
508
+ if (type) params.set("type", type);
509
+ if (subtype) params.set("subtype", subtype);
510
+ const qs = params.toString();
511
+ const res = await bridgeFetch(
512
+ `/api/cli/engines${qs ? `?${qs}` : ""}`,
513
+ // Pane-relayed on the add-in side — needs the engine budget, not the 8s
514
+ // list timeout (which bailed before the pane could ever answer). The
515
+ // delegated tokens authorize the pane-free catalog (login_required
516
+ // without them); older add-ins ignore the headers.
517
+ { timeoutMs: engineTimeoutMs(), headers: await graphHeaders(backendURL, { signIn }) },
518
+ backendURL,
519
+ );
520
+ const data = await res.json();
521
+ const arr = Array.isArray(data?.engines) ? data.engines : [];
522
+ const env = deriveEnvLabel(backendURL);
523
+ return {
524
+ engines: arr.map((e) => ({ ...e, env })),
525
+ catalog: readCatalogSummary(data),
526
+ };
527
+ });
528
+ }
529
+
530
+ // A scope (scope:all / scope:sharepoint) is not one environment's object: it is
531
+ // the same saved search target, named identically on every deployment. Pooling
532
+ // two backends' catalogs therefore repeats it verbatim — same id, same label —
533
+ // which reads as two different things to choose between. Real engines DO differ
534
+ // per environment and keep their duplicates; their `env` tag tells them apart.
535
+ export function isScopeRow(e) {
536
+ return e?.type === "scope" || /^scope:/.test(String(e?.id ?? ""));
537
+ }
538
+
539
+ export async function listEngines({ type = null, subtype = null, backendURL = null } = {}) {
540
+ const sessions = backendURL ? [sessionForBackend(backendURL)] : sessionsInScope();
541
+ // Fanned out over several environments, the listing must not block on a
542
+ // browser sign-in for one of them — the user asked for a catalog, not for
543
+ // `local`'s Microsoft 365 login. One environment in scope keeps the
544
+ // automatic sign-in (that IS the environment being used).
545
+ const signIn = sessions.length === 1;
546
+ const settled = await Promise.allSettled(
547
+ sessions.map((s) => listEnginesForBackend(s.backendURL, { type, subtype, signIn })),
548
+ );
549
+ const engines = [];
550
+ const catalogs = [];
551
+ const failures = [];
552
+ // Which environments actually contributed. NOT derivable from the pooled rows
553
+ // any more: a second environment that only repeated the scopes leaves no row
554
+ // of its own, and the human renderer keys its `[env]` tags off this.
555
+ const envs = [];
556
+ // Scope id → the one pooled row kept for it.
557
+ const scopeRows = new Map();
558
+ let fulfilledCount = 0;
559
+ settled.forEach((r, i) => {
560
+ if (r.status === "fulfilled") {
561
+ fulfilledCount += 1;
562
+ envs.push(deriveEnvLabel(sessions[i].backendURL));
563
+ for (const e of r.value.engines) {
564
+ const id = typeof e?.id === "string" ? e.id : null;
565
+ if (id && isScopeRow(e)) {
566
+ const kept = scopeRows.get(id);
567
+ if (kept) {
568
+ // Contributed by more than one environment, so it belongs to none
569
+ // of them in particular — drop the tag rather than crediting
570
+ // whichever backend happened to answer first.
571
+ kept.env = null;
572
+ continue;
573
+ }
574
+ const row = { ...e };
575
+ scopeRows.set(id, row);
576
+ engines.push(row);
577
+ continue;
578
+ }
579
+ engines.push(e);
580
+ }
581
+ if (r.value.catalog) catalogs.push(r.value.catalog);
582
+ } else {
583
+ failures.push({ env: deriveEnvLabel(sessions[i].backendURL), err: r.reason });
584
+ }
585
+ });
586
+ // Mirror listOpenPresentations: only surface an error when nothing answered.
587
+ if (engines.length === 0 && fulfilledCount === 0 && failures.length > 0) {
588
+ if (failures.length === 1) throw failures[0].err;
589
+ throw new Error(
590
+ "slidemind: every paired backend failed to list engines:\n" +
591
+ failures.map((f) => ` ${f.env}: ${f.err.message}`).join("\n"),
592
+ );
593
+ }
594
+ // Fanned out, the summary is the sum of what each environment reported: the
595
+ // engines are pooled into one list, so their counts pool too. SCOPES are the
596
+ // exception — the same named targets everywhere, deduped above — so summing
597
+ // them double-counts; the distinct total is the largest environment's count,
598
+ // or the number of distinct scope rows actually pooled when that is larger.
599
+ // (The catalog counts are pre-filter and the rows post-filter, which is why
600
+ // both terms are needed.) `filtered` is the same question asked of every
601
+ // backend, so any one of them saying yes is the answer. Null when no backend
602
+ // supplied one (old add-in / pane relay).
603
+ const catalog = catalogs.length
604
+ ? {
605
+ totalEngines: catalogs.reduce((n, c) => n + c.totalEngines, 0),
606
+ totalScopes: Math.max(...catalogs.map((c) => c.totalScopes), scopeRows.size),
607
+ filtered: catalogs.some((c) => c.filtered),
608
+ emptyEngines: catalogs.reduce((n, c) => n + c.emptyEngines, 0),
609
+ }
610
+ : null;
611
+ return {
612
+ engines,
613
+ catalog,
614
+ envs,
615
+ failures: failures.map((f) => ({
616
+ env: f.env,
617
+ message: /login_required/.test(f.err?.message ?? "") || f.err?.code === "login_required"
618
+ ? `Microsoft 365 sign-in needed (run 'slidemind login --backend ${f.env}')`
619
+ : f.err?.message ?? String(f.err),
620
+ })),
621
+ };
622
+ }