slidemind 0.3.13 → 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 +19 -0
  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 +11 -804
  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,695 @@
1
+ // HTTP transport to the add-in's cloud relay: sessions in scope, bridgeFetch /
2
+ // bridgeStreamToFile, the CLI version floor, capability probes, transient retry,
3
+ // and the delegated Graph/SharePoint token attach.
4
+
5
+ import fs from "node:fs";
6
+ import http from "node:http";
7
+ import https from "node:https";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { deriveEnvLabel, envPhrase, getSessionFor, readSessions } from "../config.mjs";
11
+ import { ensureSignedIn, getGraphToken, getSharePointToken } from "../graph-auth.mjs";
12
+ import { updateCliInstruction } from "../../install-channel.mjs";
13
+
14
+ // This CLI's own version, for the add-in's minCliVersion floor (see
15
+ // assertCliVersionSupported). Read lazily from the package manifest so the
16
+ // module stays importable even from an odd install layout.
17
+ let cliVersionCache = null;
18
+
19
+ export function cliVersion() {
20
+ if (cliVersionCache) return cliVersionCache;
21
+ try {
22
+ const pkgPath = path.join(
23
+ path.dirname(fileURLToPath(import.meta.url)),
24
+ "..",
25
+ "..",
26
+ "..",
27
+ "package.json",
28
+ );
29
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
30
+ cliVersionCache = typeof pkg.version === "string" ? pkg.version : "0.0.0";
31
+ } catch {
32
+ cliVersionCache = "0.0.0";
33
+ }
34
+ return cliVersionCache;
35
+ }
36
+
37
+ // Numeric-aware dotted-version compare (no prerelease semantics — the CLI
38
+ // releases plain X.Y.Z). Returns <0 / 0 / >0 like a comparator.
39
+ export function compareCliVersions(a, b) {
40
+ const pa = String(a ?? "").split(".").map((n) => parseInt(n, 10) || 0);
41
+ const pb = String(b ?? "").split(".").map((n) => parseInt(n, 10) || 0);
42
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
43
+ const d = (pa[i] || 0) - (pb[i] || 0);
44
+ if (d !== 0) return d < 0 ? -1 : 1;
45
+ }
46
+ return 0;
47
+ }
48
+
49
+ // The one error every version-floor check raises, so the probe path
50
+ // (assertCliVersionSupported) and the per-response header path are
51
+ // indistinguishable to callers and to the skills that relay the text.
52
+ export function cliUpdateRequiredError(min) {
53
+ const err = new Error(
54
+ `slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
55
+ `supports (${min}). ${updateCliInstruction()}`,
56
+ );
57
+ err.code = "cli_update_required";
58
+ return err;
59
+ }
60
+
61
+ // `doctor` and `ready` exist to EXPLAIN a broken connection, version skew
62
+ // included — they render it as a structured blocker with an `actor` and a
63
+ // nextAction. Throwing the raw error out of bridgeFetch would break the one
64
+ // command that diagnoses the problem, so they opt out of the header check
65
+ // (their own ladder still reports cli_update_required, terminally).
66
+ let diagnosticMode = false;
67
+
68
+ export function setDiagnosticMode(on) {
69
+ diagnosticMode = on !== false;
70
+ }
71
+
72
+ // Every add-in response carries the version floor in `x-slidemind-min-cli`.
73
+ // Checking it here covers routes no capability probe guards — the pane ops
74
+ // (search/read/insert) and pairing itself — for zero extra round-trips, and it
75
+ // fires BEFORE the status handling below so a stale CLI hitting a route that
76
+ // does not exist yet reads "run slidemind update" instead of a bare 404.
77
+ export function enforceVersionFloor(response) {
78
+ if (diagnosticMode) return;
79
+ const min = response?.headers?.["x-slidemind-min-cli"];
80
+ if (typeof min !== "string" || !min) return;
81
+ if (compareCliVersions(cliVersion(), min) >= 0) return;
82
+ throw cliUpdateRequiredError(min);
83
+ }
84
+
85
+ export function isLoopbackHostname(hostname) {
86
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
87
+ }
88
+
89
+ // The fan-out probe (listing presentations across every paired backend) uses a
90
+ // shorter timeout than ordinary calls so one dead/slow backend — a local relay
91
+ // that isn't running — can't stall the whole sweep for the full 35s default.
92
+ export function listTimeoutMs() {
93
+ return (
94
+ parseInt(process.env.SLIDEMIND_BRIDGE_LIST_TIMEOUT_MS || "8000", 10) || 8000
95
+ );
96
+ }
97
+
98
+ // Engine ops (list / search / download / extract) are RELAYED through the task
99
+ // pane: the server waits up to ~25s for a suspended pane to reconnect, then up
100
+ // to 60s for the pane's ack. Budget above that sum so a typed pane error
101
+ // (pane_suspended / pane_timeout) wins over a client timeout. Note `ls engines`
102
+ // is pane-relayed too — using the 8s list timeout there masked the real
103
+ // pane_suspended as a bare "timed out after 8000ms".
104
+ export function engineTimeoutMs() {
105
+ return (
106
+ parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
107
+ 90000
108
+ );
109
+ }
110
+
111
+ export function readinessTimeoutMs() {
112
+ return (
113
+ parseInt(process.env.SLIDEMIND_BRIDGE_READY_TIMEOUT_MS || "20000", 10) ||
114
+ 20000
115
+ );
116
+ }
117
+
118
+ export const NOT_PAIRED_MESSAGE =
119
+ "slidemind is not connected. In PowerPoint open slidemind → Settings → " +
120
+ "General → Connect CLI, press the link button, and run the command it shows.";
121
+
122
+ // The one-command remedy every host-precondition error now points at:
123
+ // `slidemind ready` runs authorization → reachability → version → window →
124
+ // design ladder and materializes the bundle, so a stuck author has a single
125
+ // next step instead of a scavenger hunt across doctor/presentations/download.
126
+ // Appended (never substituted) to the existing prose so callers' assert.match
127
+ // on the original text keeps passing.
128
+ export const READY_HINT =
129
+ "Run `slidemind ready` to check the connection and set up in one step.";
130
+
131
+ // pickPresentationEntry's variant: the caller already HAS a host, it's just
132
+ // ambiguous — `ready` resolves the window AND the design in one shot.
133
+ export const READY_HINT_PICK =
134
+ "Or run `slidemind ready` first — it resolves the window and design in one step.";
135
+
136
+ // One transient blip — a pane mid-reconnect after a backend deploy, a stale
137
+ // registration the pane is about to refresh — should not fail a whole skill
138
+ // step. Idempotent reads retry a couple of times; mutations never do (the
139
+ // request may already have been delivered).
140
+ export function transientRetryDelays() {
141
+ const raw = process.env.SLIDEMIND_BRIDGE_RETRY_DELAYS_MS || "1500,4000";
142
+ return raw
143
+ .split(",")
144
+ .map((s) => parseInt(s.trim(), 10))
145
+ .filter((n) => Number.isFinite(n) && n >= 0);
146
+ }
147
+
148
+ // A 401 from either bridge transport. Carries the remediation as its message
149
+ // AND a machine-readable shape, because callers that probe several backends in
150
+ // a row have to tell "this environment doesn't have that record" (routing
151
+ // information) from "this session is dead" (the user's problem) — see
152
+ // resolveSearchBackend.
153
+ export function sessionInvalidError() {
154
+ // Deliberately does NOT say `logout --all`: a 401 means the add-in answered
155
+ // "no such session" (revoked in Settings → Connect CLI, or expired). Running
156
+ // `setup` with a fresh command replaces this backend's record in place, so
157
+ // nothing has to be wiped first — and a user with two environments who wipes
158
+ // everything on every 401 is how the 2026-08-27 reconnect loop happened.
159
+ const err = new Error(
160
+ "slidemind session is invalid or expired: the add-in no longer knows this session " +
161
+ "(revoked under Settings → General → Connect CLI, or expired). Reconnect with a fresh " +
162
+ "command from that panel; other environments and your Microsoft 365 sign-in stay as they are.",
163
+ );
164
+ err.status = 401;
165
+ err.code = "session_invalid";
166
+ return err;
167
+ }
168
+
169
+ // True for the 401 class above, and for anything else that arrives with a 401
170
+ // status (an older path, or a rethrown response error).
171
+ export function isSessionInvalidError(err) {
172
+ if (!err) return false;
173
+ if (err.status === 401 || err.code === "session_invalid") return true;
174
+ return /slidemind session is invalid or expired/i.test(String(err.message ?? ""));
175
+ }
176
+
177
+ export function isTransientBridgeError(err) {
178
+ if (!err) return false;
179
+ // pane_suspended needs the user (foreground PowerPoint), not a blind retry —
180
+ // the relay already waited for a reconnect before returning it.
181
+ if (err.code === "pane_suspended") return false;
182
+ if (err.status === 404 || err.status === 502 || err.status === 503) return true;
183
+ if (err.status === 409 && err.code === "pane_disconnected") return true;
184
+ return /relay unreachable/i.test(String(err.message || ""));
185
+ }
186
+
187
+ export async function withTransientRetry(fn, isRetryable = isTransientBridgeError) {
188
+ const delays = transientRetryDelays();
189
+ for (let attempt = 0; ; attempt += 1) {
190
+ try {
191
+ return await fn();
192
+ } catch (err) {
193
+ if (attempt >= delays.length || !isRetryable(err)) throw err;
194
+ await new Promise((resolve) => setTimeout(resolve, delays[attempt]));
195
+ }
196
+ }
197
+ }
198
+
199
+ // The paired backends in scope for this invocation. Defaults to every paired
200
+ // session (fan-out); `SLIDEMIND_BACKEND` narrows it to one (the CLI sets that
201
+ // env from a `--backend <url|env>` flag) for speed or to disambiguate. Throws
202
+ // the same actionable message as a missing pairing when nothing matches.
203
+ export function sessionsInScope() {
204
+ const all = readSessions();
205
+ if (all.length === 0) throw new Error(NOT_PAIRED_MESSAGE);
206
+ const scope = process.env.SLIDEMIND_BACKEND;
207
+ if (scope && scope.trim()) {
208
+ const want = scope.trim();
209
+ const match = all.filter(
210
+ (s) =>
211
+ s.backendURL === want ||
212
+ s.label === want ||
213
+ deriveEnvLabel(s.backendURL) === want ||
214
+ s.backendURL.includes(want),
215
+ );
216
+ if (match.length === 0) {
217
+ throw new Error(
218
+ `slidemind is not paired with '${want}'. Run 'slidemind whoami' to see paired environments.`,
219
+ );
220
+ }
221
+ return match;
222
+ }
223
+ return all;
224
+ }
225
+
226
+ // Resolve the credential for one backend (or the default session when no
227
+ // backendURL is given — the back-compat single-session path).
228
+ export function sessionForBackend(backendURL) {
229
+ if (backendURL) {
230
+ const s = getSessionFor(backendURL);
231
+ if (!s)
232
+ throw new Error(
233
+ `slidemind is not paired with '${deriveEnvLabel(backendURL)}'.`,
234
+ );
235
+ return s;
236
+ }
237
+ const all = readSessions();
238
+ if (all.length === 0) throw new Error(NOT_PAIRED_MESSAGE);
239
+ return all[0];
240
+ }
241
+
242
+ export async function bridgeFetch(pathPart, init = {}, backendURL = null) {
243
+ const session = sessionForBackend(backendURL);
244
+ const url = `${session.backendURL}${pathPart}`;
245
+ const headers = {
246
+ accept: "application/json",
247
+ ...init.headers,
248
+ authorization: `Bearer ${session.sessionToken}`,
249
+ };
250
+
251
+ let response;
252
+ try {
253
+ response = await nodeRequest(url, { ...init, headers });
254
+ } catch (err) {
255
+ if (err && /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(String(err.message || err))) {
256
+ throw new Error(
257
+ `slidemind relay unreachable${envPhrase(deriveEnvLabel(session.backendURL), "on")}. Check your connection; ` +
258
+ "if it persists, reconnect from the add-in's Connect CLI panel.",
259
+ );
260
+ }
261
+ throw err;
262
+ }
263
+ enforceVersionFloor(response);
264
+ if (response.status === 401) throw sessionInvalidError();
265
+ if (response.status < 200 || response.status >= 300) {
266
+ let parsed = null;
267
+ try {
268
+ parsed = response.body ? JSON.parse(response.body) : null;
269
+ } catch {
270
+ /* non-JSON error body — fall back to the raw text */
271
+ }
272
+ // Prefer the machine-readable `code` (e.g. "pane_suspended",
273
+ // "engine_auth_expired"); fall back to the human `error` string for routes
274
+ // that don't emit a discrete code yet. Keep the human text in the message.
275
+ const machineCode =
276
+ parsed && typeof parsed.code === "string" ? parsed.code : null;
277
+ const humanError =
278
+ parsed && typeof parsed.error === "string" ? parsed.error : null;
279
+ const detail =
280
+ humanError ?? (response.body ? response.body.slice(0, 400) : "");
281
+ const err = new Error(
282
+ `slidemind bridge ${response.status} ${response.statusText} for ${pathPart}` +
283
+ (detail ? `: ${detail}` : ""),
284
+ );
285
+ err.status = response.status;
286
+ const code = machineCode ?? humanError;
287
+ if (code) err.code = code;
288
+ if (parsed) err.body = parsed;
289
+ throw err;
290
+ }
291
+ return {
292
+ json: async () => (response.body ? JSON.parse(response.body) : null),
293
+ text: async () => response.body,
294
+ // Exposed for routes that carry their answer partly in headers (the
295
+ // source-presentation read reports slide counts and truncation there, so a
296
+ // caller learns the deck was windowed without parsing the Markdown).
297
+ headers: response.headers ?? {},
298
+ };
299
+ }
300
+
301
+ export function nodeRequest(urlString, init = {}) {
302
+ const url = new URL(urlString);
303
+ const isHttps = url.protocol === "https:";
304
+ const transport = isHttps ? https : http;
305
+ const body = init.body ?? null;
306
+ const headers = { ...(init.headers ?? {}) };
307
+ if (body && headers["content-length"] === undefined && headers["Content-Length"] === undefined) {
308
+ headers["content-length"] = Buffer.byteLength(body);
309
+ }
310
+
311
+ const timeoutMs =
312
+ init.timeoutMs ??
313
+ (parseInt(process.env.SLIDEMIND_BRIDGE_TIMEOUT_MS || "35000", 10) || 35000);
314
+ return new Promise((resolve, reject) => {
315
+ const req = transport.request(
316
+ url,
317
+ {
318
+ method: init.method ?? "GET",
319
+ headers,
320
+ // Validate the cloud's real cert; only skip for a loopback dev backend
321
+ // (self-signed) when pointing the CLI at a local add-in for development.
322
+ rejectUnauthorized: isHttps ? !isLoopbackHostname(url.hostname) : undefined,
323
+ },
324
+ (res) => {
325
+ const chunks = [];
326
+ res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
327
+ res.on("end", () => {
328
+ resolve({
329
+ status: res.statusCode ?? 0,
330
+ statusText: res.statusMessage ?? "",
331
+ headers: res.headers,
332
+ body: Buffer.concat(chunks).toString("utf8"),
333
+ });
334
+ });
335
+ },
336
+ );
337
+ // Default 35s > the relay's 25s wait-for-pane-reconnect, so a suspended
338
+ // pane surfaces a typed `pane_suspended` rather than a client timeout. A
339
+ // real timeout is tagged `bridge_timeout` so non-idempotent callers
340
+ // (insert) know the request may already have been delivered and must NOT
341
+ // auto-retry it.
342
+ req.setTimeout(timeoutMs, () => {
343
+ const err = new Error(
344
+ `slidemind bridge request timed out after ${timeoutMs}ms`,
345
+ );
346
+ err.code = "bridge_timeout";
347
+ req.destroy(err);
348
+ });
349
+ req.on("error", reject);
350
+ if (body) req.write(body);
351
+ req.end();
352
+ });
353
+ }
354
+
355
+ // A streaming sibling of `nodeRequest`, for responses too large to hold in
356
+ // memory. `nodeRequest` concatenates the whole body and decodes it as UTF-8 —
357
+ // fine for JSON, but it would both balloon memory and CORRUPT binary. This pipes
358
+ // a success response straight to `destPath` and never materializes it.
359
+ //
360
+ // Error and JSON bodies are still buffered: both are small by construction (an
361
+ // error message, or the inline SVG the download route answers with), and the
362
+ // caller needs them whole to act on.
363
+ export function nodeRequestToFile(urlString, init = {}, destPath) {
364
+ const url = new URL(urlString);
365
+ const isHttps = url.protocol === "https:";
366
+ const transport = isHttps ? https : http;
367
+ const body = init.body ?? null;
368
+ const headers = { ...(init.headers ?? {}) };
369
+ if (
370
+ body &&
371
+ headers["content-length"] === undefined &&
372
+ headers["Content-Length"] === undefined
373
+ ) {
374
+ headers["content-length"] = Buffer.byteLength(body);
375
+ }
376
+ const timeoutMs =
377
+ init.timeoutMs ??
378
+ (parseInt(process.env.SLIDEMIND_BRIDGE_TIMEOUT_MS || "35000", 10) || 35000);
379
+
380
+ return new Promise((resolve, reject) => {
381
+ const req = transport.request(
382
+ url,
383
+ {
384
+ method: init.method ?? "GET",
385
+ headers,
386
+ rejectUnauthorized: isHttps
387
+ ? !isLoopbackHostname(url.hostname)
388
+ : undefined,
389
+ },
390
+ (res) => {
391
+ const status = res.statusCode ?? 0;
392
+ const contentType = String(res.headers["content-type"] ?? "");
393
+ const buffered = status < 200 || status >= 300 || contentType.includes("application/json");
394
+ if (buffered) {
395
+ const chunks = [];
396
+ res.on("data", (c) => chunks.push(Buffer.from(c)));
397
+ res.on("end", () =>
398
+ resolve({
399
+ status,
400
+ statusText: res.statusMessage ?? "",
401
+ headers: res.headers,
402
+ body: Buffer.concat(chunks).toString("utf8"),
403
+ streamed: false,
404
+ }),
405
+ );
406
+ res.on("error", reject);
407
+ return;
408
+ }
409
+ const out = fs.createWriteStream(destPath);
410
+ let bytesWritten = 0;
411
+ const total = Number.parseInt(
412
+ String(res.headers["content-length"] ?? ""),
413
+ 10,
414
+ );
415
+ res.on("data", (c) => {
416
+ bytesWritten += c.length;
417
+ init.onProgress?.(bytesWritten, Number.isFinite(total) ? total : null);
418
+ });
419
+ res.pipe(out);
420
+ // A stream that dies mid-flight must reject, not resolve with a truncated
421
+ // file — the caller deletes the partial and surfaces the failure.
422
+ res.on("error", (err) => {
423
+ out.destroy();
424
+ reject(err);
425
+ });
426
+ out.on("error", reject);
427
+ out.on("finish", () =>
428
+ resolve({
429
+ status,
430
+ statusText: res.statusMessage ?? "",
431
+ headers: res.headers,
432
+ bytesWritten,
433
+ streamed: true,
434
+ }),
435
+ );
436
+ },
437
+ );
438
+ req.setTimeout(timeoutMs, () => {
439
+ const err = new Error(
440
+ `slidemind bridge request timed out after ${timeoutMs}ms`,
441
+ );
442
+ err.code = "bridge_timeout";
443
+ req.destroy(err);
444
+ });
445
+ req.on("error", reject);
446
+ if (body) req.write(body);
447
+ req.end();
448
+ });
449
+ }
450
+
451
+ // `bridgeFetch` for a response streamed to disk. Shares the session lookup,
452
+ // auth header and non-2xx error mapping so a streaming call fails exactly like
453
+ // every other bridge call.
454
+ export async function bridgeStreamToFile(pathPart, init, destPath, backendURL = null) {
455
+ const session = sessionForBackend(backendURL);
456
+ const url = `${session.backendURL}${pathPart}`;
457
+ const headers = {
458
+ accept: "application/octet-stream, application/json",
459
+ ...init.headers,
460
+ authorization: `Bearer ${session.sessionToken}`,
461
+ };
462
+ let response;
463
+ try {
464
+ response = await nodeRequestToFile(url, { ...init, headers }, destPath);
465
+ } catch (err) {
466
+ if (
467
+ err &&
468
+ /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(
469
+ String(err.message || err),
470
+ )
471
+ ) {
472
+ throw new Error(
473
+ `slidemind relay unreachable${envPhrase(deriveEnvLabel(session.backendURL), "on")}. Check your connection; ` +
474
+ "if it persists, reconnect from the add-in's Connect CLI panel.",
475
+ );
476
+ }
477
+ throw err;
478
+ }
479
+ enforceVersionFloor(response);
480
+ if (response.status === 401) throw sessionInvalidError();
481
+ if (response.status < 200 || response.status >= 300) {
482
+ let parsed = null;
483
+ try {
484
+ parsed = response.body ? JSON.parse(response.body) : null;
485
+ } catch {
486
+ /* non-JSON error body */
487
+ }
488
+ const machineCode =
489
+ parsed && typeof parsed.code === "string" ? parsed.code : null;
490
+ const humanError =
491
+ parsed && typeof parsed.error === "string" ? parsed.error : null;
492
+ const detail =
493
+ humanError ?? (response.body ? response.body.slice(0, 400) : "");
494
+ const err = new Error(
495
+ `slidemind bridge ${response.status} ${response.statusText} for ${pathPart}` +
496
+ (detail ? `: ${detail}` : ""),
497
+ );
498
+ err.status = response.status;
499
+ const code = machineCode ?? humanError;
500
+ if (code) err.code = code;
501
+ if (parsed) err.body = parsed;
502
+ throw err;
503
+ }
504
+ return response;
505
+ }
506
+
507
+ export function envInt(name, fallback) {
508
+ const n = parseInt(process.env[name] || "", 10);
509
+ return Number.isFinite(n) && n > 0 ? n : fallback;
510
+ }
511
+
512
+ export function sleep(ms) {
513
+ return new Promise((resolve) => setTimeout(resolve, ms));
514
+ }
515
+
516
+ // One capability probe per backend per process. Keyed by the RESOLVED backend
517
+ // URL (callers always resolve before probing), so multiple paired backends on
518
+ // mixed versions each get their own answer.
519
+ export const backendCapabilitiesCache = new Map();
520
+
521
+ // The probe result, failure included. Every ordinary caller wants the graceful
522
+ // `{}` (an older backend simply 404s this path), but a caller that is DIAGNOSING
523
+ // has to see a 401 — "this session is revoked" must never be reported as "this
524
+ // add-in is too old". Kept as one cached promise so the distinction costs no
525
+ // extra round-trip.
526
+ export function backendCapabilitiesResult(backendURL) {
527
+ let probe = backendCapabilitiesCache.get(backendURL);
528
+ if (!probe) {
529
+ probe = (async () => {
530
+ try {
531
+ const res = await bridgeFetch(
532
+ "/api/cli/capabilities",
533
+ { timeoutMs: listTimeoutMs() },
534
+ backendURL,
535
+ );
536
+ return { caps: (await res.json()) ?? {}, error: null };
537
+ } catch (err) {
538
+ // Older backends 404 this path (with a non-JSON body) — no
539
+ // capabilities: sync render, no version floor.
540
+ return { caps: {}, error: err };
541
+ }
542
+ })();
543
+ backendCapabilitiesCache.set(backendURL, probe);
544
+ }
545
+ return probe;
546
+ }
547
+
548
+ export async function backendCapabilities(backendURL) {
549
+ return (await backendCapabilitiesResult(backendURL)).caps;
550
+ }
551
+
552
+ export async function backendSupportsRenderJobs(backendURL) {
553
+ const caps = await backendCapabilities(backendURL);
554
+ return caps?.renderJobs === true;
555
+ }
556
+
557
+ // Pane-free brand-scoped routes (/api/cli/brands/:brandId/*): design-bundle,
558
+ // translate and render keyed by the brandId the deck's .bundle-ref.json already
559
+ // stores (presFP == brandId), instead of the pane registry — so a bound deck
560
+ // keeps building after every pane key aged out of the add-in's registry (or
561
+ // with PowerPoint closed entirely). Advertised by newer add-ins only.
562
+ export async function backendSupportsBrandRoutes(backendURL) {
563
+ const caps = await backendCapabilities(backendURL);
564
+ return caps?.brandRoutes === true;
565
+ }
566
+
567
+ // Whether a backend generates concept mockup images for a brand over the
568
+ // pane-free `/api/cli/brands/:brandId/concept-images` route (see
569
+ // generateConceptImagesForBrand). Advertised by newer add-ins only; older ones
570
+ // omit the flag and the caller degrades to "continue without".
571
+ export async function backendSupportsConceptImages(backendURL) {
572
+ const caps = await backendCapabilities(backendURL);
573
+ return caps?.conceptImages === true;
574
+ }
575
+
576
+ // Whether a backend generates regular images over the pane-free
577
+ // `/api/cli/brands/:brandId/images` route (see generateImageForBrand).
578
+ // Advertised by newer add-ins only.
579
+ export async function backendSupportsImageGeneration(backendURL) {
580
+ const caps = await backendCapabilities(backendURL);
581
+ return caps?.imageGeneration === true;
582
+ }
583
+
584
+ // Whether a backend can list the corporate designs a user may build against
585
+ // (`GET /api/cli/brands`). It is what lets `ready` bind a deck with PowerPoint
586
+ // closed — without it the only way to learn a design's identity is an open
587
+ // window. Advertised by newer add-ins only.
588
+ export async function backendSupportsBrandsList(backendURL) {
589
+ const caps = await backendCapabilities(backendURL);
590
+ return caps?.brandsList === true;
591
+ }
592
+
593
+ // Whether a backend can read the SOURCE deck behind a chosen slide search
594
+ // result (`/api/cli/presentations/:presId/source-presentation`). Advertised by
595
+ // newer add-ins only; older ones omit the flag and the caller must say so
596
+ // rather than surfacing a bare 404.
597
+ export async function backendSupportsSourcePresentation(backendURL) {
598
+ const caps = await backendCapabilities(backendURL);
599
+ return caps?.sourcePresentation === true;
600
+ }
601
+
602
+ // Pane-free ENGINE routes (`/api/cli/engines`, `/api/cli/searches/*`): engine
603
+ // discovery, search and every per-result fetch answered off the CLI session
604
+ // itself instead of being relayed through the task-pane webview — the one
605
+ // transport that a backgrounded PowerPoint is free to suspend mid-run.
606
+ // Advertised by newer add-ins only; older ones keep the pane relay, which stays
607
+ // as the compat path (see the direct-route block further down).
608
+ export async function backendSupportsEngineRoutes(backendURL) {
609
+ const caps = await backendCapabilities(backendURL);
610
+ return caps?.engineRoutes === true;
611
+ }
612
+
613
+ // Whether a backend honours a delegated Microsoft Graph token on the pane-free
614
+ // engine routes — the CLI's own identity for SharePoint-backed content
615
+ // (`slidemind login`, src/bridge/graph-auth.mjs). Gates BOTH the attach and the
616
+ // remediation text: naming `slidemind login` on an add-in that ignores the token
617
+ // would send a user through a browser flow that changes nothing.
618
+ export async function backendSupportsGraphDelegated(backendURL) {
619
+ const caps = await backendCapabilities(backendURL);
620
+ return caps?.graphDelegated === true;
621
+ }
622
+
623
+ // The add-in's hard version floor: /api/cli/capabilities advertises the oldest
624
+ // CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
625
+ // mid-rollout skew otherwise surfaces as confusing route/contract errors deep
626
+ // in a render — and prompt the one actionable fix. No-op when the backend
627
+ // doesn't advertise a floor (older add-ins) or the CLI meets it.
628
+ export async function assertCliVersionSupported(backendURL) {
629
+ const caps = await backendCapabilities(backendURL);
630
+ const min = typeof caps?.minCliVersion === "string" ? caps.minCliVersion : null;
631
+ if (!min || compareCliVersions(cliVersion(), min) >= 0) return;
632
+ throw cliUpdateRequiredError(min);
633
+ }
634
+
635
+ // The user's delegated tokens for a backend, or nulls. Attached ONLY to the
636
+ // pane-free engine ops: they are the user's own Entra identity, and render,
637
+ // bundle, whoami and the pane relay have no business carrying them (the pane
638
+ // signs its own requests with a browser token that is strictly fresher).
639
+ //
640
+ // When nothing is stored, the sign-in RUNS HERE — browser opens, the command
641
+ // blocks until the human finishes (graph-auth.ensureSignedIn, once per
642
+ // process). So login is not a separate ritual: the first engine command of a
643
+ // fresh machine walks the user through it, and every later one rides the
644
+ // stored refresh token silently. Null only when the add-in doesn't honour the
645
+ // tokens, or the sign-in was refused/abandoned — the backend then answers its
646
+ // typed `login_required`, which the blocker below renders.
647
+ export async function engineGraphToken(backendURL, { signIn = true } = {}) {
648
+ if (!backendURL) return null;
649
+ if (!(await backendSupportsGraphDelegated(backendURL))) return null;
650
+ let token = await getGraphToken(backendURL);
651
+ if (!token && signIn && (await ensureSignedIn(backendURL))) {
652
+ token = await getGraphToken(backendURL);
653
+ }
654
+ return token;
655
+ }
656
+
657
+ // The SharePoint-resource sibling (same sign-in, same refresh token): the
658
+ // backend resolves `sg:` site-group membership from the PAIR, and on a
659
+ // SharePoint-configured deployment refuses corpus search without it.
660
+ export async function engineSpToken(backendURL, { signIn = true } = {}) {
661
+ if (!backendURL) return null;
662
+ if (!(await backendSupportsGraphDelegated(backendURL))) return null;
663
+ let token = await getSharePointToken(backendURL);
664
+ if (!token && signIn && (await ensureSignedIn(backendURL))) {
665
+ token = await getSharePointToken(backendURL);
666
+ }
667
+ return token;
668
+ }
669
+
670
+ // Both delegated tokens as body fields, for the POST routes. Empty when there
671
+ // is nothing to attach, so an older backend sees the body it always did.
672
+ export async function delegatedBodyFields(backendURL) {
673
+ const [graphToken, spToken] = await Promise.all([
674
+ engineGraphToken(backendURL),
675
+ engineSpToken(backendURL),
676
+ ]);
677
+ return {
678
+ ...(graphToken ? { graphApiToken: graphToken } : {}),
679
+ ...(spToken ? { spApiToken: spToken } : {}),
680
+ };
681
+ }
682
+
683
+ // The byte routes are GETs, so the delegated tokens ride headers instead of
684
+ // body fields. Empty (not absent-valued) when there is no token, so the request
685
+ // headers are unchanged for everyone who never signed in.
686
+ export async function graphHeaders(backendURL, { signIn = true } = {}) {
687
+ const [graphToken, spToken] = await Promise.all([
688
+ engineGraphToken(backendURL, { signIn }),
689
+ engineSpToken(backendURL, { signIn }),
690
+ ]);
691
+ return {
692
+ ...(graphToken ? { "x-slidemind-graph-token": graphToken } : {}),
693
+ ...(spToken ? { "x-slidemind-sp-token": spToken } : {}),
694
+ };
695
+ }