slidemind 0.0.29 → 0.1.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.
@@ -1,75 +1,120 @@
1
- // Client for the slidemind PowerPoint add-in's local bridge.
1
+ // Client for the slidemind cloud relay.
2
2
  //
3
- // The add-in's Express middle-tier (port 3001 by default) exposes a localhost-
4
- // only, token-gated `/local/*` surface that lets a sibling local process —
5
- // this CLI, the bundled MCP server, anything running on the same machine —
6
- // enumerate the open PowerPoint windows and push slides into a specific one.
7
- //
8
- // The shared token is generated on add-in start and written to a state file.
9
- // Reading it is sufficient auth; the add-in's middleware also rejects any
10
- // non-loopback caller, so this is intentionally a same-machine-only surface.
3
+ // The CLI talks to `<backendURL>/api/cli/*` with a Bearer session token — both
4
+ // established once by `slidemind pair` (see config.mjs). The add-in's cloud
5
+ // backend runs render/bundle in-process and relays insert/replace/engine to the
6
+ // open PowerPoint pane. No localhost, no shared loopback token, no cert.
11
7
 
12
- import fs from "node:fs";
13
8
  import http from "node:http";
14
- import os from "node:os";
15
- import path from "node:path";
16
9
  import https from "node:https";
17
10
 
18
- const DEFAULT_HOST = "127.0.0.1";
19
- const DEFAULT_PORT = 3001;
20
- const TOKEN_HEADER = "x-slidemind-bridge-token";
11
+ import {
12
+ deriveEnvLabel,
13
+ getSessionFor,
14
+ readSessions,
15
+ } from "./config.mjs";
21
16
 
22
- export function bridgeStateDir() {
23
- if (process.platform === "win32") {
24
- const base = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
25
- return path.join(base, "slidemind");
26
- }
27
- const base = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
28
- return path.join(base, "slidemind");
17
+ function isLoopbackHostname(hostname) {
18
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
29
19
  }
30
20
 
31
- export function bridgeTokenPath() {
32
- return path.join(bridgeStateDir(), "bridge-token");
21
+ // The fan-out probe (listing presentations across every paired backend) uses a
22
+ // shorter timeout than ordinary calls so one dead/slow backend — a local relay
23
+ // that isn't running — can't stall the whole sweep for the full 35s default.
24
+ function listTimeoutMs() {
25
+ return (
26
+ parseInt(process.env.SLIDEMIND_BRIDGE_LIST_TIMEOUT_MS || "8000", 10) || 8000
27
+ );
33
28
  }
34
29
 
35
- export function readBridgeToken() {
36
- const file = bridgeTokenPath();
37
- let raw;
38
- try {
39
- raw = fs.readFileSync(file, "utf8");
40
- } catch (err) {
41
- if (err.code === "ENOENT") {
30
+ const NOT_PAIRED_MESSAGE =
31
+ 'slidemind is not paired. In the slidemind add-in, click "Connect CLI" ' +
32
+ "and run the shown `slidemind pair …` command, then retry.";
33
+
34
+ // One transient blip — a pane mid-reconnect after a backend deploy, a stale
35
+ // registration the pane is about to refresh — should not fail a whole skill
36
+ // step. Idempotent reads retry a couple of times; mutations never do (the
37
+ // request may already have been delivered).
38
+ function transientRetryDelays() {
39
+ const raw = process.env.SLIDEMIND_BRIDGE_RETRY_DELAYS_MS || "1500,4000";
40
+ return raw
41
+ .split(",")
42
+ .map((s) => parseInt(s.trim(), 10))
43
+ .filter((n) => Number.isFinite(n) && n >= 0);
44
+ }
45
+
46
+ function isTransientBridgeError(err) {
47
+ if (!err) return false;
48
+ // pane_suspended needs the user (foreground PowerPoint), not a blind retry —
49
+ // the relay already waited for a reconnect before returning it.
50
+ if (err.code === "pane_suspended") return false;
51
+ if (err.status === 404 || err.status === 502 || err.status === 503) return true;
52
+ if (err.status === 409 && err.code === "pane_disconnected") return true;
53
+ return /relay unreachable/i.test(String(err.message || ""));
54
+ }
55
+
56
+ async function withTransientRetry(fn) {
57
+ const delays = transientRetryDelays();
58
+ for (let attempt = 0; ; attempt += 1) {
59
+ try {
60
+ return await fn();
61
+ } catch (err) {
62
+ if (attempt >= delays.length || !isTransientBridgeError(err)) throw err;
63
+ await new Promise((resolve) => setTimeout(resolve, delays[attempt]));
64
+ }
65
+ }
66
+ }
67
+
68
+ // The paired backends in scope for this invocation. Defaults to every paired
69
+ // session (fan-out); `SLIDEMIND_BACKEND` narrows it to one (the CLI sets that
70
+ // env from a `--backend <url|env>` flag) for speed or to disambiguate. Throws
71
+ // the same actionable message as a missing pairing when nothing matches.
72
+ function sessionsInScope() {
73
+ const all = readSessions();
74
+ if (all.length === 0) throw new Error(NOT_PAIRED_MESSAGE);
75
+ const scope = process.env.SLIDEMIND_BACKEND;
76
+ if (scope && scope.trim()) {
77
+ const want = scope.trim();
78
+ const match = all.filter(
79
+ (s) =>
80
+ s.backendURL === want ||
81
+ s.label === want ||
82
+ deriveEnvLabel(s.backendURL) === want ||
83
+ s.backendURL.includes(want),
84
+ );
85
+ if (match.length === 0) {
42
86
  throw new Error(
43
- `slidemind bridge token not found at ${file}. ` +
44
- `Start the slidemind PowerPoint add-in first — it generates the token on first run.`,
87
+ `slidemind is not paired with '${want}'. Run 'slidemind whoami' to see paired environments.`,
45
88
  );
46
89
  }
47
- throw err;
48
- }
49
- const token = raw.trim();
50
- if (!token) {
51
- throw new Error(`slidemind bridge token at ${file} is empty.`);
90
+ return match;
52
91
  }
53
- return token;
92
+ return all;
54
93
  }
55
94
 
56
- function bridgeBaseUrl() {
57
- const host = process.env.SLIDEMIND_BRIDGE_HOST || DEFAULT_HOST;
58
- const port = parseInt(process.env.SLIDEMIND_BRIDGE_PORT || `${DEFAULT_PORT}`, 10);
59
- // The add-in's dev backend uses HTTPS with self-signed certs; production
60
- // (Azure Container Apps) terminates TLS upstream and exposes HTTP. Default
61
- // to HTTPS but allow override.
62
- const scheme = process.env.SLIDEMIND_BRIDGE_SCHEME || "https";
63
- return `${scheme}://${host}:${port}`;
95
+ // Resolve the credential for one backend (or the default session when no
96
+ // backendURL is given — the back-compat single-session path).
97
+ function sessionForBackend(backendURL) {
98
+ if (backendURL) {
99
+ const s = getSessionFor(backendURL);
100
+ if (!s)
101
+ throw new Error(
102
+ `slidemind is not paired with '${deriveEnvLabel(backendURL)}'.`,
103
+ );
104
+ return s;
105
+ }
106
+ const all = readSessions();
107
+ if (all.length === 0) throw new Error(NOT_PAIRED_MESSAGE);
108
+ return all[0];
64
109
  }
65
110
 
66
- async function bridgeFetch(pathPart, init = {}) {
67
- const url = `${bridgeBaseUrl()}${pathPart}`;
68
- const token = readBridgeToken();
111
+ async function bridgeFetch(pathPart, init = {}, backendURL = null) {
112
+ const session = sessionForBackend(backendURL);
113
+ const url = `${session.backendURL}${pathPart}`;
69
114
  const headers = {
70
115
  accept: "application/json",
71
116
  ...init.headers,
72
- [TOKEN_HEADER]: token,
117
+ authorization: `Bearer ${session.sessionToken}`,
73
118
  };
74
119
 
75
120
  let response;
@@ -78,12 +123,18 @@ async function bridgeFetch(pathPart, init = {}) {
78
123
  } catch (err) {
79
124
  if (err && /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(String(err.message || err))) {
80
125
  throw new Error(
81
- `slidemind bridge unreachable at ${url}. ` +
82
- `Is the slidemind PowerPoint add-in running? (npm run dev:backend in src/ui)`,
126
+ `slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
127
+ "if it persists, re-pair with `slidemind pair`.",
83
128
  );
84
129
  }
85
130
  throw err;
86
131
  }
132
+ if (response.status === 401) {
133
+ throw new Error(
134
+ "slidemind session is invalid or expired. Re-pair: open the add-in, " +
135
+ 'click "Connect CLI", and run the shown `slidemind pair …`.',
136
+ );
137
+ }
87
138
  if (response.status < 200 || response.status >= 300) {
88
139
  let parsed = null;
89
140
  try {
@@ -135,7 +186,9 @@ function nodeRequest(urlString, init = {}) {
135
186
  {
136
187
  method: init.method ?? "GET",
137
188
  headers,
138
- rejectUnauthorized: isHttps ? false : undefined,
189
+ // Validate the cloud's real cert; only skip for a loopback dev backend
190
+ // (self-signed) when pointing the CLI at a local add-in for development.
191
+ rejectUnauthorized: isHttps ? !isLoopbackHostname(url.hostname) : undefined,
139
192
  },
140
193
  (res) => {
141
194
  const chunks = [];
@@ -149,10 +202,11 @@ function nodeRequest(urlString, init = {}) {
149
202
  });
150
203
  },
151
204
  );
152
- // Default 35s > the add-in's 30s emitWithAck, so a suspended pane surfaces a
153
- // typed `pane_suspended` rather than a client timeout. A real timeout is
154
- // tagged `bridge_timeout` so non-idempotent callers (insert) know the request
155
- // may already have been delivered and must NOT auto-retry it.
205
+ // Default 35s > the relay's 25s wait-for-pane-reconnect, so a suspended
206
+ // pane surfaces a typed `pane_suspended` rather than a client timeout. A
207
+ // real timeout is tagged `bridge_timeout` so non-idempotent callers
208
+ // (insert) know the request may already have been delivered and must NOT
209
+ // auto-retry it.
156
210
  req.setTimeout(timeoutMs, () => {
157
211
  const err = new Error(
158
212
  `slidemind bridge request timed out after ${timeoutMs}ms`,
@@ -166,42 +220,159 @@ function nodeRequest(urlString, init = {}) {
166
220
  });
167
221
  }
168
222
 
223
+ // List one backend's open presentations, tagging each entry with its origin
224
+ // (backendURL + a short env label) so the merged registry can be routed and
225
+ // displayed per environment. Transient blips retry per-backend.
226
+ async function listPresentationsForBackend(backendURL) {
227
+ return withTransientRetry(async () => {
228
+ const res = await bridgeFetch(
229
+ "/api/cli/presentations",
230
+ { timeoutMs: listTimeoutMs() },
231
+ backendURL,
232
+ );
233
+ const data = await res.json();
234
+ const arr = Array.isArray(data?.presentations) ? data.presentations : [];
235
+ const env = deriveEnvLabel(backendURL);
236
+ return arr.map((p) => ({ ...p, backendURL, env }));
237
+ });
238
+ }
239
+
240
+ // Fan out across every backend in scope, concurrently. Never rejects: each
241
+ // backend's outcome is partitioned into merged presentations vs. a failure
242
+ // record, so one unreachable environment can't hide the others.
243
+ async function fanOutPresentations() {
244
+ const sessions = sessionsInScope();
245
+ const settled = await Promise.allSettled(
246
+ sessions.map((s) => listPresentationsForBackend(s.backendURL)),
247
+ );
248
+ const presentations = [];
249
+ const failures = [];
250
+ let fulfilledCount = 0;
251
+ settled.forEach((r, i) => {
252
+ const backendURL = sessions[i].backendURL;
253
+ if (r.status === "fulfilled") {
254
+ fulfilledCount += 1;
255
+ presentations.push(...r.value);
256
+ } else {
257
+ failures.push({
258
+ backendURL,
259
+ env: deriveEnvLabel(backendURL),
260
+ err: r.reason,
261
+ });
262
+ }
263
+ });
264
+ return { presentations, failures, fulfilledCount };
265
+ }
266
+
267
+ // Merged registry across all paired backends. Stays a flat array (every caller
268
+ // expects one). Preserves single-backend behaviour: when nothing is reachable
269
+ // AND no backend answered, the underlying bridge error propagates (so a typed
270
+ // code like `pane_suspended` or a 401 still surfaces) — for several failing
271
+ // backends it's an aggregate message.
169
272
  export async function listOpenPresentations() {
170
- const res = await bridgeFetch("/local/presentations");
171
- const data = await res.json();
172
- return Array.isArray(data?.presentations) ? data.presentations : [];
273
+ const { presentations, failures, fulfilledCount } =
274
+ await fanOutPresentations();
275
+ if (presentations.length === 0 && fulfilledCount === 0 && failures.length > 0) {
276
+ if (failures.length === 1) throw failures[0].err;
277
+ const err = new Error(
278
+ "slidemind: every paired backend failed to list presentations:\n" +
279
+ failures
280
+ .map((f) => ` ${f.env}: ${f.err.message}`)
281
+ .join("\n"),
282
+ );
283
+ throw err;
284
+ }
285
+ return presentations;
173
286
  }
174
287
 
175
- // Probe the bridge for an open PowerPoint host. Returns the live registry, or
176
- // throws a single actionable message — the shared precondition for every
177
- // author/render command now that the design truth lives in the add-in.
288
+ // The user/agent-facing shape of a presentation entry. `backendURL` is internal
289
+ // routing state — every caller targets a presentation by `presId` (the backend
290
+ // is re-resolved server-side from it), so the URL must never leave the process
291
+ // in any printed line or MCP payload. Keeps presId, title, fileName, connected,
292
+ // suspendedAt, and the short `env` label (the only discriminator a user sees).
293
+ export function toPublicPresentation(p) {
294
+ const { backendURL, ...rest } = p;
295
+ return rest;
296
+ }
297
+
298
+ // Like listOpenPresentations but never throws on per-backend failures — returns
299
+ // the merged registry plus which backends were unreachable, for callers that
300
+ // want to report partial results (requireHost, whoami).
301
+ export async function listOpenPresentationsDetailed() {
302
+ const { presentations, failures } = await fanOutPresentations();
303
+ return {
304
+ presentations,
305
+ unreachable: failures.map((f) => ({
306
+ backendURL: f.backendURL,
307
+ env: f.env,
308
+ error: f.err?.message ? f.err.message : String(f.err),
309
+ })),
310
+ };
311
+ }
312
+
313
+ // Probe the bridge for an open PowerPoint host across every paired backend.
314
+ // Returns the merged registry, or throws a single actionable message — the
315
+ // shared precondition for every author/render command now that the design truth
316
+ // lives in the add-in. Suspended entries (task-pane socket dropped, e.g.
317
+ // PowerPoint backgrounded) count as registered: render + bundle work without
318
+ // the pane, and a push waits server-side for the pane to reconnect.
178
319
  export async function requireHost() {
179
- let registry;
180
- try {
181
- registry = await listOpenPresentations();
182
- } catch (err) {
320
+ const { presentations, unreachable } = await listOpenPresentationsDetailed();
321
+ if (Array.isArray(presentations) && presentations.length > 0) {
322
+ return presentations;
323
+ }
324
+ // Nothing registered. Distinguish "a backend was unreachable" from "backends
325
+ // answered but no window is open" so the user gets the right next step.
326
+ if (unreachable.length > 0) {
183
327
  throw new Error(
184
328
  "Open PowerPoint with the slidemind add-in and an active corporate design, then retry.\n" +
185
- ` (bridge: ${err.message})`,
329
+ unreachable
330
+ .map((u) => ` (bridge: ${u.env}: ${u.error})`)
331
+ .join("\n"),
186
332
  );
187
333
  }
188
- if (!Array.isArray(registry) || registry.length === 0) {
334
+ throw new Error(
335
+ "No PowerPoint window is registered with the slidemind add-in.\n" +
336
+ "If PowerPoint is already open, bring it to the foreground and click the slidemind pane once so it re-registers, then retry.\n" +
337
+ "Otherwise open PowerPoint with the slidemind add-in and an active corporate design, then retry.",
338
+ );
339
+ }
340
+
341
+ // Resolve which backend a presId lives on via a LIVE fan-out list (never a
342
+ // cached map — a presId can move backends on re-pair/redeploy). Throws an
343
+ // actionable message on a miss, or an ambiguity error when the same presId is
344
+ // open on more than one reachable backend.
345
+ export async function resolveBackendForPres(presId) {
346
+ if (!presId) throw new Error("resolveBackendForPres: presId is required");
347
+ const { presentations } = await fanOutPresentations();
348
+ const matches = presentations.filter((p) => p.presId === presId);
349
+ if (matches.length === 1) return matches[0].backendURL;
350
+ if (matches.length > 1) {
351
+ const envs = matches.map((m) => m.env).join(", ");
189
352
  throw new Error(
190
- "No PowerPoint window is registered with the slidemind add-in.\n" +
191
- "Open PowerPoint with the slidemind add-in and an active corporate design, then retry.",
353
+ `Presentation '${presId}' is open on multiple environments: ${envs}. ` +
354
+ "Disambiguate with --backend <env|label> (or SLIDEMIND_BACKEND).",
192
355
  );
193
356
  }
194
- return registry;
357
+ throw new Error(
358
+ `No open PowerPoint window registered with presId='${presId}' on any paired backend. ` +
359
+ "Run 'slidemind presentations' to see what's open.",
360
+ );
195
361
  }
196
362
 
197
363
  // GET the full design bundle (tokens, css, layouts, designRules, signed asset
198
364
  // URLs, engines) for the presentation's active corporate design.
199
- export async function getDesignBundle(presId) {
365
+ export async function getDesignBundle(presId, { backendURL = null } = {}) {
200
366
  if (!presId) throw new Error("getDesignBundle: presId is required");
201
- const res = await bridgeFetch(
202
- `/local/presentations/${encodeURIComponent(presId)}/design-bundle`,
203
- );
204
- return res.json();
367
+ const be = backendURL || (await resolveBackendForPres(presId));
368
+ return withTransientRetry(async () => {
369
+ const res = await bridgeFetch(
370
+ `/api/cli/presentations/${encodeURIComponent(presId)}/design-bundle`,
371
+ {},
372
+ be,
373
+ );
374
+ return res.json();
375
+ });
205
376
  }
206
377
 
207
378
  // Server-side OOXML render: ship authored slide JSX (+ deck-local assets) and
@@ -212,23 +383,32 @@ export async function renderPptx({
212
383
  slides,
213
384
  deckAssets = null,
214
385
  etag = null,
386
+ backendURL = null,
215
387
  }) {
216
388
  if (!presId) throw new Error("renderPptx: presId is required");
217
389
  if (!slides || Object.keys(slides).length === 0) {
218
390
  throw new Error("renderPptx: slides is required");
219
391
  }
392
+ const be = backendURL || (await resolveBackendForPres(presId));
220
393
  const body = { slides };
221
394
  if (deckAssets && Object.keys(deckAssets).length > 0) {
222
395
  body.deckAssets = deckAssets;
223
396
  }
224
397
  if (etag) body.etag = etag;
225
398
  const res = await bridgeFetch(
226
- `/local/presentations/${encodeURIComponent(presId)}/render-pptx`,
399
+ `/api/cli/presentations/${encodeURIComponent(presId)}/render-pptx`,
227
400
  {
228
401
  method: "POST",
229
402
  headers: { "content-type": "application/json" },
230
403
  body: JSON.stringify(body),
404
+ // Server-side OOXML render is a heavy operation — a full deck of richly
405
+ // composed slides routinely runs well past the 35s default. Treat it like
406
+ // a mutation: give it a generous default and let env override it.
407
+ timeoutMs:
408
+ parseInt(process.env.SLIDEMIND_BRIDGE_RENDER_TIMEOUT_MS || "300000", 10) ||
409
+ 300000,
231
410
  },
411
+ be,
232
412
  );
233
413
  return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
234
414
  }
@@ -236,17 +416,25 @@ export async function renderPptx({
236
416
  // Brand-engine search through the local bridge. New add-ins run this
237
417
  // server-side with the relayed user token; older add-ins may fall back to a
238
418
  // task-pane proxy. Returns a searchId + numbered result thumbnails.
239
- export async function engineSearch({ presId, engineId, query }) {
419
+ export async function engineSearch({ presId, engineId, query, backendURL = null }) {
240
420
  if (!presId) throw new Error("engineSearch: presId is required");
241
421
  if (!engineId) throw new Error("engineSearch: engineId is required");
242
422
  if (!query) throw new Error("engineSearch: query is required");
423
+ const be = backendURL || (await resolveBackendForPres(presId));
243
424
  const res = await bridgeFetch(
244
- `/local/presentations/${encodeURIComponent(presId)}/engine/search`,
425
+ `/api/cli/presentations/${encodeURIComponent(presId)}/engine/search`,
245
426
  {
246
427
  method: "POST",
247
428
  headers: { "content-type": "application/json" },
248
429
  body: JSON.stringify({ engineId, query }),
430
+ // Engine ops relay through the pane: the server may wait up to ~25s for
431
+ // a suspended pane to reconnect, then up to 60s for the pane's ack —
432
+ // stay above that sum so typed errors win over client timeouts.
433
+ timeoutMs:
434
+ parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
435
+ 90000,
249
436
  },
437
+ be,
250
438
  );
251
439
  return res.json(); // { ok, searchId, resultCount, gridDataUri, thumbnails }
252
440
  }
@@ -259,6 +447,7 @@ export async function engineDownload({
259
447
  searchId,
260
448
  selection,
261
449
  iconColor = null,
450
+ backendURL = null,
262
451
  }) {
263
452
  if (!presId) throw new Error("engineDownload: presId is required");
264
453
  if (typeof searchId !== "number") {
@@ -267,15 +456,22 @@ export async function engineDownload({
267
456
  if (typeof selection !== "number") {
268
457
  throw new Error("engineDownload: selection (number) is required");
269
458
  }
459
+ const be = backendURL || (await resolveBackendForPres(presId));
270
460
  const body = { searchId, selection };
271
461
  if (iconColor) body.iconColor = iconColor;
272
462
  const res = await bridgeFetch(
273
- `/local/presentations/${encodeURIComponent(presId)}/engine/download`,
463
+ `/api/cli/presentations/${encodeURIComponent(presId)}/engine/download`,
274
464
  {
275
465
  method: "POST",
276
466
  headers: { "content-type": "application/json" },
277
467
  body: JSON.stringify(body),
468
+ // Same relay path as engine/search — outlast the server's reconnect
469
+ // wait + pane ack timeout.
470
+ timeoutMs:
471
+ parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
472
+ 90000,
278
473
  },
474
+ be,
279
475
  );
280
476
  return res.json(); // { ok, base64, mimeType, svgMarkup }
281
477
  }
@@ -286,12 +482,14 @@ export async function insertSlides({
286
482
  insertAt = null,
287
483
  sourceSlideIds = null,
288
484
  operationId = null,
485
+ backendURL = null,
289
486
  }) {
290
487
  if (!presId) throw new Error("insertSlides: presId is required");
291
488
  if (!base64) throw new Error("insertSlides: base64 is required");
489
+ const be = backendURL || (await resolveBackendForPres(presId));
292
490
  const body = { base64, insertAt, sourceSlideIds, operationId };
293
491
  const res = await bridgeFetch(
294
- `/local/presentations/${encodeURIComponent(presId)}/insert`,
492
+ `/api/cli/presentations/${encodeURIComponent(presId)}/insert`,
295
493
  {
296
494
  method: "POST",
297
495
  headers: { "content-type": "application/json" },
@@ -300,15 +498,22 @@ export async function insertSlides({
300
498
  parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
301
499
  1805000,
302
500
  },
501
+ be,
303
502
  );
304
503
  return res.json().catch(() => ({}));
305
504
  }
306
505
 
307
- export async function replaceSlides({ presId, base64, operationId = null }) {
506
+ export async function replaceSlides({
507
+ presId,
508
+ base64,
509
+ operationId = null,
510
+ backendURL = null,
511
+ }) {
308
512
  if (!presId) throw new Error("replaceSlides: presId is required");
309
513
  if (!base64) throw new Error("replaceSlides: base64 is required");
514
+ const be = backendURL || (await resolveBackendForPres(presId));
310
515
  const res = await bridgeFetch(
311
- `/local/presentations/${encodeURIComponent(presId)}/replace`,
516
+ `/api/cli/presentations/${encodeURIComponent(presId)}/replace`,
312
517
  {
313
518
  method: "POST",
314
519
  headers: { "content-type": "application/json" },
@@ -317,23 +522,33 @@ export async function replaceSlides({ presId, base64, operationId = null }) {
317
522
  parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
318
523
  1805000,
319
524
  },
525
+ be,
320
526
  );
321
527
  return res.json().catch(() => ({}));
322
528
  }
323
529
 
324
- // Pick a target presentation given the live registry and an optional explicit
325
- // presId. Returns the presId to push to, or throws with a helpful message if
326
- // the choice is ambiguous.
327
- export function pickPresentation(registry, explicitPresId) {
530
+ // Pick a target presentation given the (possibly multi-backend) registry and an
531
+ // optional explicit presId. Returns the matched registry ENTRY (which carries
532
+ // `backendURL`/`env` after a fan-out list), or throws with a helpful message if
533
+ // the choice is ambiguous. Callers that already hold the entry can pass its
534
+ // `backendURL` straight to the targeted ops, skipping a second fan-out.
535
+ export function pickPresentationEntry(registry, explicitPresId) {
328
536
  if (explicitPresId) {
329
- const match = registry.find((p) => p.presId === explicitPresId);
330
- if (!match) {
537
+ const matches = registry.filter((p) => p.presId === explicitPresId);
538
+ if (matches.length === 0) {
331
539
  const ids = registry.map((p) => p.presId).join(", ") || "(none registered)";
332
540
  throw new Error(
333
541
  `No open PowerPoint window registered with presId='${explicitPresId}'. Live: ${ids}`,
334
542
  );
335
543
  }
336
- return match.presId;
544
+ if (matches.length > 1) {
545
+ const envs = matches.map((m) => m.env || "?").join(", ");
546
+ throw new Error(
547
+ `Presentation '${explicitPresId}' is open on multiple environments: ${envs}. ` +
548
+ "Disambiguate with --backend <env|label> (or SLIDEMIND_BACKEND).",
549
+ );
550
+ }
551
+ return matches[0];
337
552
  }
338
553
  if (registry.length === 0) {
339
554
  throw new Error(
@@ -341,11 +556,21 @@ export function pickPresentation(registry, explicitPresId) {
341
556
  "Open a presentation, ensure the add-in is loaded, and try again.",
342
557
  );
343
558
  }
344
- if (registry.length === 1) return registry[0].presId;
559
+ if (registry.length === 1) return registry[0];
345
560
  const list = registry
346
- .map((p) => ` ${p.presId}\t${p.title || p.fileName || "(untitled)"}`)
561
+ .map(
562
+ (p) =>
563
+ ` ${p.presId}\t${p.env ? `[${p.env}]\t` : ""}${p.title || p.fileName || "(untitled)"}` +
564
+ (p.connected === false || p.suspendedAt ? "\t(suspended — PowerPoint in background)" : ""),
565
+ )
347
566
  .join("\n");
348
567
  throw new Error(
349
568
  `Multiple PowerPoint windows are open. Pick one with --to <presId>:\n${list}`,
350
569
  );
351
570
  }
571
+
572
+ // Back-compat shim: returns just the presId string. Prefer
573
+ // pickPresentationEntry when you need the origin backend.
574
+ export function pickPresentation(registry, explicitPresId) {
575
+ return pickPresentationEntry(registry, explicitPresId).presId;
576
+ }