slidemind 0.1.6 → 0.1.8

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,11 +6,13 @@
6
6
  // `review` gate.
7
7
  //
8
8
  // The CLI ships ZERO exporter code: the authored slide JSX + deck-local assets
9
- // are POSTed to the add-in's `render-pptx` route, which renders them against
10
- // the presentation's own corporate design using the SAME OOXML exporter the
11
- // in-app design persona uses. The returned base64 .pptx is then inserted /
12
- // replaced (push) or written to disk (build-pptx). An open PowerPoint host with
13
- // an active corporate design is therefore a hard precondition for both paths.
9
+ // are POSTed to the add-in's render routes, which render them against the
10
+ // deck's corporate design using the SAME OOXML exporter the in-app design
11
+ // persona uses. The returned base64 .pptx is then inserted / replaced (push)
12
+ // or written to disk (build-pptx). A BOUND deck renders pane-free over the
13
+ // brand routes (PowerPoint backgrounded or closed); only the INSERT half of a
14
+ // push, and unbound decks (whose brandId must come from the pane registry),
15
+ // still need a registered PowerPoint window.
14
16
 
15
17
  import fs from "node:fs";
16
18
  import path from "node:path";
@@ -21,6 +23,7 @@ import {
21
23
  pickPresentationEntry,
22
24
  renderPptx,
23
25
  replaceSlides,
26
+ requireHost,
24
27
  requireRenderHost,
25
28
  translateDeck,
26
29
  waitForPaneReady,
@@ -93,8 +96,12 @@ function gatherDeckAssets(deckDir) {
93
96
  // ref rewritten, and the render retried once against the current design.
94
97
  export async function renderDeckToBase64(
95
98
  deckDir,
96
- { presId, backendURL = null, onProgress = null },
99
+ { presId = null, brandId = null, backendURL = null, onProgress = null },
97
100
  ) {
101
+ if (!presId && !brandId) {
102
+ throw new Error("renderDeckToBase64: presId or brandId is required");
103
+ }
104
+ const deckName = path.basename(deckDir);
98
105
  const { slides, nativeSlides, orderWarning } = gatherSlides(deckDir);
99
106
  const hasJsx = Object.keys(slides).length > 0;
100
107
  const deckAssets = gatherDeckAssets(deckDir);
@@ -103,9 +110,10 @@ export async function renderDeckToBase64(
103
110
  // silently no-ops), the local HTML/editor preview can't resolve the deck's
104
111
  // design files, AND local capture has no engine/bundle to read. `download-
105
112
  // bundle <deck>` normally writes this; here we self-heal so `push`/`build-pptx`
106
- // work even when it wasn't run explicitly.
113
+ // work even when it wasn't run explicitly. (The brand path implies a bound
114
+ // deck — its brandId came from the ref — so this only fires with a presId.)
107
115
  let ref = readDeckBundleRef(deckDir);
108
- if (!ref?.etag) {
116
+ if (!ref?.etag && presId) {
109
117
  try {
110
118
  const mat = await materializeBundle(presId, { backendURL });
111
119
  writeDeckBundleRef(deckDir, { presFP: mat.presFP, etag: mat.etag });
@@ -129,6 +137,7 @@ export async function renderDeckToBase64(
129
137
  // .pptx slides ride alongside on either path so the server splices them by
130
138
  // play-order filename.
131
139
  const attemptRender = async (etag) => {
140
+ const target = { presId, brandId, deckName, backendURL };
132
141
  if (useLocalCapture) {
133
142
  let captured;
134
143
  try {
@@ -139,13 +148,13 @@ export async function renderDeckToBase64(
139
148
  "falling back to the server render\n",
140
149
  );
141
150
  useLocalCapture = false;
142
- return renderPptx({ presId, slides, deckAssets, nativeSlides, etag, backendURL, onProgress });
151
+ return renderPptx({ ...target, slides, deckAssets, nativeSlides, etag, onProgress });
143
152
  }
144
153
  const n = Array.isArray(captured?.slides) ? captured.slides.length : 0;
145
154
  onProgress?.({ done: n, total: n, stage: "assembling" });
146
- return translateDeck({ presId, captured, nativeSlides, etag, backendURL });
155
+ return translateDeck({ ...target, captured, nativeSlides, etag });
147
156
  }
148
- return renderPptx({ presId, slides, deckAssets, nativeSlides, etag, backendURL, onProgress });
157
+ return renderPptx({ ...target, slides, deckAssets, nativeSlides, etag, onProgress });
149
158
  };
150
159
 
151
160
  let result;
@@ -157,15 +166,18 @@ export async function renderDeckToBase64(
157
166
  // design truth lives in the add-in, so the right move is forward: refresh
158
167
  // the local bundle (so previews/lint/capture-engine match), re-bind the
159
168
  // deck, and render once against the CURRENT design. A second stale (CD
160
- // changed again mid-flight) propagates.
169
+ // changed again mid-flight) propagates. The refresh rides the SAME target
170
+ // the render used — the brand route stays pane-free end to end.
161
171
  process.stderr.write(
162
172
  "corporate design changed since this deck was authored — refreshing the " +
163
173
  "bundle and retrying the render against the current design…\n",
164
174
  );
165
- const mat = await materializeBundle(presId, {
166
- backendURL,
167
- preferBrandId: ref?.presFP ?? null,
168
- });
175
+ const mat = brandId
176
+ ? await materializeBundle(null, { backendURL, brandId })
177
+ : await materializeBundle(presId, {
178
+ backendURL,
179
+ preferBrandId: ref?.presFP ?? null,
180
+ });
169
181
  writeDeckBundleRef(deckDir, { presFP: mat.presFP, etag: mat.etag });
170
182
  // The bundle dir is keyed by presFP, so a refreshed capture engine landed
171
183
  // in place — re-evaluate in case it appeared (or vanished) on this sync.
@@ -327,6 +339,45 @@ export async function viewDraft(
327
339
  return { slides, warnings };
328
340
  }
329
341
 
342
+ // A brand-route miss is definitive for the pane-free path but NOT for the
343
+ // deck: old add-in (no brandRoutes), legacy non-brand presFP in the ref,
344
+ // grant/tenant miss, or a brand with no bundle — every one of these still
345
+ // works through the pane flow, which needs no grant and re-binds the ref.
346
+ function isBrandRouteMiss(err) {
347
+ return (
348
+ err?.code === "brand_route_unavailable" ||
349
+ err?.code === "unknown_brand" ||
350
+ err?.code === "no_active_cd" ||
351
+ err?.status === 404
352
+ );
353
+ }
354
+
355
+ // Pane-free render for a BOUND deck: sync the bundle over the brand route
356
+ // (proves the backend + grant and refreshes the capture engine/etag exactly
357
+ // like `ready`), re-bind, then render by brandId — no PowerPoint window
358
+ // involved. Returns null when the brand route can't serve this deck (caller
359
+ // falls back to the pane flow); render failures (lint, stale-after-retry)
360
+ // propagate, they are real.
361
+ async function tryDetachedRender(deckDir, deckRef, onProgress) {
362
+ const brandId = deckRef.presFP;
363
+ let backendURL;
364
+ try {
365
+ const mat = await materializeBundle(null, { brandId });
366
+ writeDeckBundleRef(deckDir, { presFP: mat.presFP, etag: mat.etag });
367
+ backendURL = mat.backendURL ?? null;
368
+ if (!backendURL) return null;
369
+ } catch (err) {
370
+ if (isBrandRouteMiss(err)) return null;
371
+ throw err;
372
+ }
373
+ const result = await renderDeckToBase64(deckDir, {
374
+ brandId,
375
+ backendURL,
376
+ onProgress,
377
+ });
378
+ return { result, backendURL };
379
+ }
380
+
330
381
  async function renderWithRetarget(deckDir, entry, onProgress) {
331
382
  const run = (target) =>
332
383
  renderDeckToBase64(deckDir, {
@@ -379,20 +430,66 @@ export async function buildAndPush({
379
430
  }
380
431
 
381
432
  const deckRef = readDeckBundleRef(deckDir);
382
- const registry = await requireRenderHost();
383
- const entry = pickPresentationEntry(registry, presentationId, {
384
- preferBrandId: deckRef?.presFP ?? null,
385
- });
386
- const rendered = await renderWithRetarget(deckDir, entry, onProgress);
387
- const renderEntry = rendered.entry;
388
- const presId = renderEntry.presId;
389
- const backendURL = renderEntry.backendURL ?? null;
390
- const result = rendered.result;
433
+
434
+ // --- Render half. Pane-free for a bound deck (the window may be closed or
435
+ // backgrounded during the render); the pane matters only for the insert. ---
436
+ let result = null;
437
+ let renderEntry = null;
438
+ if (!presentationId && deckRef?.presFP) {
439
+ const detached = await tryDetachedRender(deckDir, deckRef, onProgress);
440
+ if (detached) result = detached.result;
441
+ }
442
+ if (!result) {
443
+ const registry = await requireRenderHost();
444
+ const entry = pickPresentationEntry(registry, presentationId, {
445
+ preferBrandId: deckRef?.presFP ?? null,
446
+ });
447
+ const rendered = await renderWithRetarget(deckDir, entry, onProgress);
448
+ renderEntry = rendered.entry;
449
+ result = rendered.result;
450
+ }
451
+
391
452
  const base64 = result.base64;
392
453
  const bytes = Buffer.byteLength(base64, "base64");
393
454
  const mode = replace ? "replace" : "insert";
394
455
  const operationId = `push-${randomUUID()}`;
395
456
 
457
+ // The render survived without a window; don't lose it when the insert can't
458
+ // find one — save the .pptx next to the deck and tell the caller (bin prints
459
+ // the recovery instructions).
460
+ const saveFallback = (presIdForResult) => {
461
+ const fallbackFile = path.join(deckDir, `${path.basename(deckDir)}.pptx`);
462
+ fs.writeFileSync(fallbackFile, Buffer.from(base64, "base64"));
463
+ return {
464
+ presId: presIdForResult,
465
+ mode,
466
+ bytes,
467
+ operationId,
468
+ response: null,
469
+ warnings: result.warnings ?? [],
470
+ skippedSlides: result.skippedSlides ?? [],
471
+ paneSuspended: true,
472
+ fallbackFile,
473
+ };
474
+ };
475
+
476
+ // --- Insert half: needs a registered window. On the pane-free render path
477
+ // none was resolved yet; if none exists at all, the deck is saved instead of
478
+ // thrown away. ---
479
+ if (!renderEntry) {
480
+ let registry;
481
+ try {
482
+ registry = await requireHost();
483
+ } catch {
484
+ return saveFallback(null);
485
+ }
486
+ renderEntry = pickPresentationEntry(registry, presentationId, {
487
+ preferBrandId: deckRef?.presFP ?? null,
488
+ });
489
+ }
490
+ const presId = renderEntry.presId;
491
+ const backendURL = renderEntry.backendURL ?? null;
492
+
396
493
  // The SAME operationId on every attempt: the pane keeps an LRU of applied
397
494
  // operationIds and drops duplicates, so a retry after an ambiguous failure
398
495
  // can never double-insert.
@@ -418,16 +515,25 @@ export async function buildAndPush({
418
515
  try {
419
516
  resp = await doPush(presId, backendURL);
420
517
  } catch (err) {
421
- // Two recoverable shapes, both meaning "nothing was delivered":
518
+ // Three recoverable shapes:
422
519
  // - pane_suspended (503): the server waited ~25s for a reconnect and gave
423
- // up — the pane needs the user (foreground PowerPoint / click the pane).
520
+ // up — nothing was delivered; the pane needs the user (foreground
521
+ // PowerPoint / click the pane).
522
+ // - pane_timeout (504): the pane looked connected but never acked within
523
+ // the server's 60s window (just-frozen webview). Delivery is ambiguous,
524
+ // but the retry below re-sends the SAME operationId, so the pane-side
525
+ // dedup LRU makes it safe either way. The server marked the pane
526
+ // suspended on the way out, so the same client-side wake wait applies.
424
527
  // - 404: the presId vanished from the registry — a pane reconnect mints a
425
528
  // NEW presId for the same window (presId churn).
426
529
  // Wait client-side for the pane (rematching by fileName+brandId covers the
427
- // churn), then retry ONCE. A `bridge_timeout` is NOT caught: the insert
428
- // may already have been delivered, and the operationId dedup only helps if
429
- // we re-send — which we deliberately leave to the user for that case.
430
- const recoverable = err?.code === "pane_suspended" || err?.status === 404;
530
+ // churn), then retry ONCE. A client-side `bridge_timeout` is NOT caught:
531
+ // there the server-side outcome is entirely unknown (the request may still
532
+ // be executing), so a re-send is deliberately left to the user.
533
+ const recoverable =
534
+ err?.code === "pane_suspended" ||
535
+ err?.code === "pane_timeout" ||
536
+ err?.status === 404;
431
537
  if (!recoverable) throw err;
432
538
  const live = await waitForPaneReady(presId, {
433
539
  backendURL,
@@ -440,25 +546,11 @@ export async function buildAndPush({
440
546
  ),
441
547
  });
442
548
  if (!live) {
443
- if (err?.code !== "pane_suspended") throw err;
444
- // The pane never woke up. Don't lose the render: save the .pptx next to
445
- // the deck and tell the caller (bin prints the recovery instructions).
446
- const fallbackFile = path.join(
447
- deckDir,
448
- `${path.basename(deckDir)}.pptx`,
449
- );
450
- fs.writeFileSync(fallbackFile, Buffer.from(base64, "base64"));
451
- return {
452
- presId,
453
- mode,
454
- bytes,
455
- operationId,
456
- response: null,
457
- warnings: result.warnings ?? [],
458
- skippedSlides: result.skippedSlides ?? [],
459
- paneSuspended: true,
460
- fallbackFile,
461
- };
549
+ if (err?.code !== "pane_suspended" && err?.code !== "pane_timeout") {
550
+ throw err;
551
+ }
552
+ // The pane never woke up — same recovery as no window at all.
553
+ return saveFallback(presId);
462
554
  }
463
555
  livePresId = live.presId;
464
556
  resp = await doPush(live.presId, live.backendURL ?? backendURL);
@@ -474,9 +566,11 @@ export async function buildAndPush({
474
566
  };
475
567
  }
476
568
 
477
- // Render a deck to a .pptx file (the `slidemind build-pptx` path). Still
478
- // requires an open host because the render happens server-side — but NOT an
479
- // awake task pane: the render runs in the add-in's cloud backend.
569
+ // Render a deck to a .pptx file (the `slidemind build-pptx` path). For a BOUND
570
+ // deck against a backend serving brand routes this is fully pane-free — no
571
+ // PowerPoint window needed at all (backgrounded or closed). Otherwise it needs
572
+ // an open host because the brandId must come from the pane registry — but
573
+ // still not an awake pane: the render runs in the add-in's cloud backend.
480
574
  export async function buildPptxFile({
481
575
  deckDir,
482
576
  presentationId = null,
@@ -485,6 +579,25 @@ export async function buildPptxFile({
485
579
  }) {
486
580
  if (!deckDir) throw new Error("buildPptxFile: deckDir is required");
487
581
  const deckRef = readDeckBundleRef(deckDir);
582
+ const out = outPath || path.join(deckDir, `${path.basename(deckDir)}.pptx`);
583
+
584
+ // Pane-free path. `--to <presId>` names a window explicitly and therefore
585
+ // keeps the registry path.
586
+ if (!presentationId && deckRef?.presFP) {
587
+ const detached = await tryDetachedRender(deckDir, deckRef, onProgress);
588
+ if (detached) {
589
+ fs.writeFileSync(out, Buffer.from(detached.result.base64, "base64"));
590
+ return {
591
+ outPath: out,
592
+ presId: null,
593
+ windowless: true,
594
+ slideCount: detached.result.slideCount ?? null,
595
+ warnings: detached.result.warnings ?? [],
596
+ skippedSlides: detached.result.skippedSlides ?? [],
597
+ };
598
+ }
599
+ }
600
+
488
601
  const registry = await requireRenderHost();
489
602
  const entry = pickPresentationEntry(registry, presentationId, {
490
603
  preferBrandId: deckRef?.presFP ?? null,
@@ -495,8 +608,6 @@ export async function buildPptxFile({
495
608
  onProgress,
496
609
  );
497
610
  const presId = renderEntry.presId;
498
- const out =
499
- outPath || path.join(deckDir, `${path.basename(deckDir)}.pptx`);
500
611
  fs.writeFileSync(out, Buffer.from(result.base64, "base64"));
501
612
  return {
502
613
  outPath: out,
@@ -45,7 +45,11 @@ import path from "node:path";
45
45
  import http from "node:http";
46
46
  import https from "node:https";
47
47
 
48
- import { getDesignBundle } from "./client.mjs";
48
+ import {
49
+ getDesignBundle,
50
+ getDesignBundleForBrand,
51
+ getDesignBundleForBrandAnyBackend,
52
+ } from "./client.mjs";
49
53
 
50
54
  // Highest bundle wire-contract version this CLI understands. The add-in stamps
51
55
  // `schemaVersion` on every bundle; a higher value means the CLI is too old.
@@ -65,6 +69,21 @@ export function bundleDirForPresFP(presFP) {
65
69
  return path.join(bundleCacheRoot(), sanitizeKey(presFP));
66
70
  }
67
71
 
72
+ // Zero-GET lookup of a materialized bundle's meta by presFP: reads the on-disk
73
+ // bundle.json materializeBundle wrote and returns the parsed record, or null
74
+ // when that CD was never materialized here / the file is unreadable. `new`'s
75
+ // stateless auto-bind uses this to confirm a resolved design's bundle already
76
+ // exists locally BEFORE writing a deck ref — no network, no throw.
77
+ export function readMaterializedBundleMeta(presFP) {
78
+ if (!presFP) return null;
79
+ try {
80
+ const metaPath = path.join(bundleDirForPresFP(presFP), "bundle.json");
81
+ return JSON.parse(fs.readFileSync(metaPath, "utf8"));
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
68
87
  // Wipe the whole materialized bundle cache so the next build/push/download
69
88
  // re-fetches every design bundle fresh from the add-in. `slidemind init` calls
70
89
  // this: init is the deliberate (re)setup point — and `slidemind update`
@@ -220,13 +239,31 @@ function buildReusableImagesManifest(extractedImages) {
220
239
  // a bundle re-produce (engines, image-source guidelines, sample curation) and
221
240
  // deployment-owned content (workflow refs, the lint engine) can never go
222
241
  // stale here. `force` re-downloads binaries even when their etags match.
223
- // Returns { dir, presFP, etag, version, downloaded, pruned, engineUpdated,
224
- // warnings }.
242
+ // Target: `presId` (pane registry), or `brandId` in options for the pane-free
243
+ // brand route (presId may then be null; backendURL null = fan-out).
244
+ // Returns { dir, presFP, etag, version, backendURL, downloaded, pruned,
245
+ // engineUpdated, warnings }.
225
246
  export async function materializeBundle(
226
247
  presId,
227
- { force = false, backendURL = null, preferBrandId = null } = {},
248
+ { force = false, backendURL = null, preferBrandId = null, brandId = null } = {},
228
249
  ) {
229
- const wire = await getDesignBundle(presId, { backendURL, preferBrandId });
250
+ // Two fetch paths for the same wire: by presId through the pane registry
251
+ // (needs a registered window), or pane-free by brandId through the brand
252
+ // route (bound deck, PowerPoint optional). Everything below operates on the
253
+ // wire and is identical for both.
254
+ let wire;
255
+ let wireBackendURL = backendURL;
256
+ if (brandId) {
257
+ if (backendURL) {
258
+ wire = await getDesignBundleForBrand(brandId, { backendURL });
259
+ } else {
260
+ const resolved = await getDesignBundleForBrandAnyBackend(brandId);
261
+ wire = resolved.wire;
262
+ wireBackendURL = resolved.backendURL;
263
+ }
264
+ } else {
265
+ wire = await getDesignBundle(presId, { backendURL, preferBrandId });
266
+ }
230
267
  if (!wire || !wire.presFP) {
231
268
  throw new Error("design-bundle response is missing presFP");
232
269
  }
@@ -445,6 +482,9 @@ export async function materializeBundle(
445
482
 
446
483
  const meta = {
447
484
  presFP,
485
+ // In the brand model presFP == brandId; recorded explicitly so consumers
486
+ // (and humans reading the cache) don't need to know that equivalence.
487
+ brandId: presFP,
448
488
  version: wire.version ?? null,
449
489
  etag: wire.etag ?? null,
450
490
  scope: wire.scope ?? null,
@@ -483,6 +523,9 @@ export async function materializeBundle(
483
523
  presFP,
484
524
  etag: wire.etag ?? null,
485
525
  version: wire.version ?? null,
526
+ // Which backend actually served the wire — meaningful on the brandId
527
+ // fan-out path, where the caller didn't know it up front.
528
+ backendURL: wireBackendURL,
486
529
  downloaded,
487
530
  pruned,
488
531
  engineUpdated,
@@ -613,8 +656,7 @@ export function isImageEngine(engine) {
613
656
  // `kind`. Returns null when the CD exposes no image engine (e.g. only a
614
657
  // slide-search engine) — callers surface a "wire an image engine" message
615
658
  // rather than falling back to an engine the bridge can't insert. Single source
616
- // of truth for the heuristic shared by the CLI dispatcher (`slidemind search`)
617
- // and the MCP `slidemind_search_engine` tool.
659
+ // of truth for the heuristic used by the CLI dispatcher (`slidemind search`).
618
660
  export function pickImageEngine(engines) {
619
661
  const list = Array.isArray(engines) ? engines : [];
620
662
  return list.find(isImageEngine) ?? null;
@@ -171,8 +171,8 @@ export async function captureDeckLocally({ deckDir, bundleDir, onProgress }) {
171
171
  }
172
172
  const meta = readBundleMeta(bundleDir);
173
173
  const version = meta.captureEngineVersion ?? "0";
174
- // `?v=` busts Node's module cache so a long-lived process (the MCP server)
175
- // picks up a refreshed engine at the same path after a bundle sync.
174
+ // `?v=` busts Node's module cache so a re-import picks up a refreshed engine
175
+ // at the same path after a bundle sync.
176
176
  const engine = await import(
177
177
  `${pathToFileURL(enginePath).href}?v=${encodeURIComponent(version)}`
178
178
  );