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.
- package/.claude-plugin/plugin.json +1 -7
- package/CLAUDE.md +21 -15
- package/README.md +21 -10
- package/bin/slidemind.mjs +836 -35
- package/package.json +2 -5
- package/skills/create-slides/SKILL.md +85 -60
- package/skills/search/SKILL.md +27 -27
- package/src/bridge/build-and-push.mjs +166 -55
- package/src/bridge/bundle.mjs +49 -7
- package/src/bridge/capture.mjs +2 -2
- package/src/bridge/client.mjs +374 -44
- package/src/bridge/engine-download.mjs +89 -0
- package/src/{mcp → bridge}/recolor.mjs +10 -9
- package/src/builders/build.mjs +261 -23
- package/src/builders/gate-server.mjs +223 -0
- package/src/builders/lint.mjs +2 -2
- package/src/builders/plan-md.mjs +216 -0
- package/src/builders/plan-overlay.js +747 -0
- package/src/builders/plan.mjs +575 -0
- package/src/builders/resolve-skill.mjs +40 -5
- package/src/builders/review-overlay.js +172 -16
- package/src/builders/review.mjs +139 -80
- package/src/installers/claude.mjs +10 -17
- package/src/installers/codex.mjs +14 -77
- package/src/installers/opencode.mjs +10 -18
- package/src/lib/reused-slides.mjs +2 -4
- package/src/mcp/server.mjs +0 -958
package/src/bridge/client.mjs
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
// open PowerPoint pane. No localhost, no shared loopback token, no cert.
|
|
7
7
|
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
|
+
import fs from "node:fs";
|
|
9
10
|
import http from "node:http";
|
|
10
11
|
import https from "node:https";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
11
14
|
|
|
12
15
|
import {
|
|
13
16
|
deriveEnvLabel,
|
|
@@ -15,6 +18,39 @@ import {
|
|
|
15
18
|
readSessions,
|
|
16
19
|
} from "./config.mjs";
|
|
17
20
|
|
|
21
|
+
// This CLI's own version, for the add-in's minCliVersion floor (see
|
|
22
|
+
// assertCliVersionSupported). Read lazily from the package manifest so the
|
|
23
|
+
// module stays importable even from an odd install layout.
|
|
24
|
+
let cliVersionCache = null;
|
|
25
|
+
export function cliVersion() {
|
|
26
|
+
if (cliVersionCache) return cliVersionCache;
|
|
27
|
+
try {
|
|
28
|
+
const pkgPath = path.join(
|
|
29
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
30
|
+
"..",
|
|
31
|
+
"..",
|
|
32
|
+
"package.json",
|
|
33
|
+
);
|
|
34
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
35
|
+
cliVersionCache = typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
36
|
+
} catch {
|
|
37
|
+
cliVersionCache = "0.0.0";
|
|
38
|
+
}
|
|
39
|
+
return cliVersionCache;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Numeric-aware dotted-version compare (no prerelease semantics — the CLI
|
|
43
|
+
// releases plain X.Y.Z). Returns <0 / 0 / >0 like a comparator.
|
|
44
|
+
export function compareCliVersions(a, b) {
|
|
45
|
+
const pa = String(a ?? "").split(".").map((n) => parseInt(n, 10) || 0);
|
|
46
|
+
const pb = String(b ?? "").split(".").map((n) => parseInt(n, 10) || 0);
|
|
47
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
|
48
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
49
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
18
54
|
function isLoopbackHostname(hostname) {
|
|
19
55
|
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
|
|
20
56
|
}
|
|
@@ -52,6 +88,19 @@ const NOT_PAIRED_MESSAGE =
|
|
|
52
88
|
'slidemind is not paired. In the slidemind add-in, click "Connect CLI" ' +
|
|
53
89
|
"and run the shown `slidemind pair …` command, then retry.";
|
|
54
90
|
|
|
91
|
+
// The one-command remedy every host-precondition error now points at:
|
|
92
|
+
// `slidemind ready` runs the whole pairing → reachability → version → window →
|
|
93
|
+
// design ladder and materializes the bundle, so a stuck author has a single
|
|
94
|
+
// next step instead of a scavenger hunt across doctor/presentations/download.
|
|
95
|
+
// Appended (never substituted) to the existing prose so callers' assert.match
|
|
96
|
+
// on the original text keeps passing.
|
|
97
|
+
const READY_HINT =
|
|
98
|
+
"Run `slidemind ready` to check the connection and set up in one step.";
|
|
99
|
+
// pickPresentationEntry's variant: the caller already HAS a host, it's just
|
|
100
|
+
// ambiguous — `ready` resolves the window AND the design in one shot.
|
|
101
|
+
const READY_HINT_PICK =
|
|
102
|
+
"Or run `slidemind ready` first — it resolves the window and design in one step.";
|
|
103
|
+
|
|
55
104
|
// One transient blip — a pane mid-reconnect after a backend deploy, a stale
|
|
56
105
|
// registration the pane is about to refresh — should not fail a whole skill
|
|
57
106
|
// step. Idempotent reads retry a couple of times; mutations never do (the
|
|
@@ -270,12 +319,23 @@ async function readinessForBackend(backendURL) {
|
|
|
270
319
|
const presentations = Array.isArray(data.presentations)
|
|
271
320
|
? data.presentations.map((p) => ({ ...p, backendURL, env }))
|
|
272
321
|
: [];
|
|
322
|
+
// Version-floor check for diagnostics (doctor / connection_status): newer
|
|
323
|
+
// add-ins advertise minCliVersion in the readiness capabilities, so the
|
|
324
|
+
// skew is visible BEFORE a render trips over it.
|
|
325
|
+
const minCliVersion =
|
|
326
|
+
typeof data.capabilities?.minCliVersion === "string"
|
|
327
|
+
? data.capabilities.minCliVersion
|
|
328
|
+
: null;
|
|
273
329
|
return {
|
|
274
330
|
env,
|
|
275
331
|
backendURL,
|
|
276
332
|
ok: true,
|
|
277
333
|
readinessV1: data.readinessV1 === true,
|
|
278
334
|
capabilities: data.capabilities ?? null,
|
|
335
|
+
minCliVersion,
|
|
336
|
+
cliUpdateRequired: Boolean(
|
|
337
|
+
minCliVersion && compareCliVersions(cliVersion(), minCliVersion) < 0,
|
|
338
|
+
),
|
|
279
339
|
presentations,
|
|
280
340
|
summary: data.summary ?? null,
|
|
281
341
|
};
|
|
@@ -372,8 +432,10 @@ export async function listOpenPresentations() {
|
|
|
372
432
|
// The user/agent-facing shape of a presentation entry. `backendURL` is internal
|
|
373
433
|
// routing state — every caller targets a presentation by `presId` (the backend
|
|
374
434
|
// is re-resolved server-side from it), so the URL must never leave the process
|
|
375
|
-
// in any printed line or
|
|
376
|
-
// suspendedAt,
|
|
435
|
+
// in any printed line or command output. Keeps everything else — presId, title,
|
|
436
|
+
// fileName, connected, suspendedAt, the short `env` label, and the readiness
|
|
437
|
+
// fields (connectionState, designState, capabilities, nextAction) that agents
|
|
438
|
+
// parse from `presentations --json` to decide whether/how to target a pane.
|
|
377
439
|
export function toPublicPresentation(p) {
|
|
378
440
|
const { backendURL, ...rest } = p;
|
|
379
441
|
return rest;
|
|
@@ -412,13 +474,15 @@ export async function requireHost() {
|
|
|
412
474
|
"Open PowerPoint with the slidemind add-in and an active corporate design, then retry.\n" +
|
|
413
475
|
unreachable
|
|
414
476
|
.map((u) => ` (bridge: ${u.env}: ${u.error})`)
|
|
415
|
-
.join("\n")
|
|
477
|
+
.join("\n") +
|
|
478
|
+
`\n${READY_HINT}`,
|
|
416
479
|
);
|
|
417
480
|
}
|
|
418
481
|
throw new Error(
|
|
419
482
|
"No PowerPoint window is registered with the slidemind add-in.\n" +
|
|
420
483
|
"If PowerPoint is already open, bring it to the foreground and click the slidemind pane once so it re-registers, then retry.\n" +
|
|
421
|
-
"Otherwise open PowerPoint with the slidemind add-in and an active corporate design, then retry
|
|
484
|
+
"Otherwise open PowerPoint with the slidemind add-in and an active corporate design, then retry.\n" +
|
|
485
|
+
READY_HINT,
|
|
422
486
|
);
|
|
423
487
|
}
|
|
424
488
|
|
|
@@ -467,6 +531,32 @@ async function waitForReadyPresentation(predicate, { timeoutMs = null } = {}) {
|
|
|
467
531
|
}
|
|
468
532
|
}
|
|
469
533
|
|
|
534
|
+
// The bounded poll behind `slidemind presentations`. Unlike
|
|
535
|
+
// waitForReadyPresentation it PURPOSELY does NOT early-return on an empty
|
|
536
|
+
// registry: the empty window right after a pane reload / PowerPoint wake is
|
|
537
|
+
// exactly the flaky case this papers over. Polls the merged registry every
|
|
538
|
+
// SLIDEMIND_BRIDGE_READY_POLL_MS (default 1.5s) up to
|
|
539
|
+
// SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS (default 6s), stopping as soon as at
|
|
540
|
+
// least one pane is pane-ready. A `timeoutMs` of 0 means single-shot — one
|
|
541
|
+
// fetch, no sleep. Returns { all, ready } so the caller can list EVERY entry
|
|
542
|
+
// (suspended included, with its status) yet still know which are live. Does not
|
|
543
|
+
// touch waitForReadyPresentation / requirePaneHost — agent target selection
|
|
544
|
+
// still uses only ready panes.
|
|
545
|
+
export async function listReadyPresentationsWithWait({ timeoutMs = null } = {}) {
|
|
546
|
+
const waitMs =
|
|
547
|
+
timeoutMs ?? envInt("SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS", 6000);
|
|
548
|
+
const pollMs = envInt("SLIDEMIND_BRIDGE_READY_POLL_MS", 1500);
|
|
549
|
+
const deadline = Date.now() + waitMs;
|
|
550
|
+
for (;;) {
|
|
551
|
+
const all = await listOpenPresentations();
|
|
552
|
+
const ready = all.filter(isPaneReadyPresentation);
|
|
553
|
+
if (ready.length > 0 || waitMs <= 0 || Date.now() >= deadline) {
|
|
554
|
+
return { all, ready };
|
|
555
|
+
}
|
|
556
|
+
await sleep(pollMs);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
470
560
|
export async function requireRenderHost({ timeoutMs = null } = {}) {
|
|
471
561
|
const first = await listOpenPresentationsDetailed();
|
|
472
562
|
if (first.presentations.some(isRenderReadyPresentation)) {
|
|
@@ -482,7 +572,8 @@ export async function requireRenderHost({ timeoutMs = null } = {}) {
|
|
|
482
572
|
}
|
|
483
573
|
throw new Error(
|
|
484
574
|
"PowerPoint is registered with the slidemind add-in, but no window is render-ready yet. " +
|
|
485
|
-
"Wait for the corporate design to finish matching, or pick/create a corporate design in the add-in, then retry."
|
|
575
|
+
"Wait for the corporate design to finish matching, or pick/create a corporate design in the add-in, then retry. " +
|
|
576
|
+
READY_HINT,
|
|
486
577
|
);
|
|
487
578
|
}
|
|
488
579
|
return requireHost();
|
|
@@ -491,19 +582,20 @@ export async function requireRenderHost({ timeoutMs = null } = {}) {
|
|
|
491
582
|
export async function requirePaneHost({ timeoutMs = null } = {}) {
|
|
492
583
|
const first = await listOpenPresentationsDetailed();
|
|
493
584
|
if (first.presentations.some(isPaneReadyPresentation)) {
|
|
494
|
-
return first.presentations;
|
|
585
|
+
return first.presentations.filter(isPaneReadyPresentation);
|
|
495
586
|
}
|
|
496
587
|
if (first.presentations.length > 0) {
|
|
497
588
|
const waited = await waitForReadyPresentation(isPaneReadyPresentation, {
|
|
498
589
|
timeoutMs,
|
|
499
590
|
});
|
|
500
|
-
if (Array.isArray(waited)) return waited;
|
|
591
|
+
if (Array.isArray(waited)) return waited.filter(isPaneReadyPresentation);
|
|
501
592
|
if (waited.presentations.some(isPaneReadyPresentation)) {
|
|
502
|
-
return waited.presentations;
|
|
593
|
+
return waited.presentations.filter(isPaneReadyPresentation);
|
|
503
594
|
}
|
|
504
595
|
throw new Error(
|
|
505
596
|
"PowerPoint is registered with the slidemind add-in, but the task pane is not ready for pane operations. " +
|
|
506
|
-
"Bring PowerPoint forward and click or reopen the slidemind pane, then retry."
|
|
597
|
+
"Bring PowerPoint forward and click or reopen the slidemind pane, then retry. " +
|
|
598
|
+
READY_HINT,
|
|
507
599
|
);
|
|
508
600
|
}
|
|
509
601
|
return requireHost();
|
|
@@ -612,7 +704,7 @@ export async function waitForPaneReady(
|
|
|
612
704
|
}
|
|
613
705
|
|
|
614
706
|
// The one actionable line a human needs when a pane op is waiting. stderr so
|
|
615
|
-
// it reaches the terminal from the CLI
|
|
707
|
+
// it reaches the terminal from the CLI.
|
|
616
708
|
function paneWaitNotice(seconds) {
|
|
617
709
|
process.stderr.write(
|
|
618
710
|
"task pane suspended — click the slidemind pane in PowerPoint to wake it " +
|
|
@@ -639,6 +731,64 @@ export async function getDesignBundle(
|
|
|
639
731
|
});
|
|
640
732
|
}
|
|
641
733
|
|
|
734
|
+
// GET the design bundle by brandId over the pane-free brand route. 404
|
|
735
|
+
// `unknown_brand` is definitive (unknown / cross-tenant / not granted to this
|
|
736
|
+
// user), so no transient retry. Requires the resolved backendURL — use
|
|
737
|
+
// getDesignBundleForBrandAnyBackend when it isn't known yet.
|
|
738
|
+
export async function getDesignBundleForBrand(brandId, { backendURL } = {}) {
|
|
739
|
+
if (!brandId) {
|
|
740
|
+
throw new Error("getDesignBundleForBrand: brandId is required");
|
|
741
|
+
}
|
|
742
|
+
if (!backendURL) {
|
|
743
|
+
throw new Error("getDesignBundleForBrand: backendURL is required");
|
|
744
|
+
}
|
|
745
|
+
const res = await bridgeFetch(
|
|
746
|
+
`/api/cli/brands/${encodeURIComponent(brandId)}/design-bundle`,
|
|
747
|
+
{},
|
|
748
|
+
backendURL,
|
|
749
|
+
);
|
|
750
|
+
return res.json();
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Resolve WHICH paired backend serves a brand by asking each one that
|
|
754
|
+
// advertises brand routes — a bound deck has no open window, so there is no
|
|
755
|
+
// registry entry to resolve a backend from. presFP/brandId is globally unique
|
|
756
|
+
// across deployments (the bundle-cache contract), so at most one backend
|
|
757
|
+
// answers 2xx. Returns { wire, backendURL }; throws a typed
|
|
758
|
+
// `brand_route_unavailable` when no backend serves the brand (old add-ins,
|
|
759
|
+
// unknown brand, missing grant) so callers fall back to the pane flow.
|
|
760
|
+
export async function getDesignBundleForBrandAnyBackend(brandId) {
|
|
761
|
+
if (!brandId) {
|
|
762
|
+
throw new Error("getDesignBundleForBrandAnyBackend: brandId is required");
|
|
763
|
+
}
|
|
764
|
+
const sessions = sessionsInScope();
|
|
765
|
+
const failures = [];
|
|
766
|
+
for (const s of sessions) {
|
|
767
|
+
try {
|
|
768
|
+
if (!(await backendSupportsBrandRoutes(s.backendURL))) {
|
|
769
|
+
failures.push({ env: deriveEnvLabel(s.backendURL), reason: "no brand routes" });
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
const wire = await getDesignBundleForBrand(brandId, {
|
|
773
|
+
backendURL: s.backendURL,
|
|
774
|
+
});
|
|
775
|
+
return { wire, backendURL: s.backendURL };
|
|
776
|
+
} catch (err) {
|
|
777
|
+
failures.push({
|
|
778
|
+
env: deriveEnvLabel(s.backendURL),
|
|
779
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
|
|
784
|
+
const err = new Error(
|
|
785
|
+
`no paired backend serves design '${brandId}' over the pane-free brand ` +
|
|
786
|
+
`route${detail ? ` (${detail})` : ""}.`,
|
|
787
|
+
);
|
|
788
|
+
err.code = "brand_route_unavailable";
|
|
789
|
+
throw err;
|
|
790
|
+
}
|
|
791
|
+
|
|
642
792
|
// List the LIVE engine catalog (NOT the bundle's curated set) for the paired
|
|
643
793
|
// backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
|
|
644
794
|
// when several environments are paired this fans out and tags each engine with
|
|
@@ -665,8 +815,8 @@ async function listEnginesForBackend(backendURL, { type = null, subtype = null }
|
|
|
665
815
|
});
|
|
666
816
|
}
|
|
667
817
|
|
|
668
|
-
export async function listEngines({ type = null, subtype = null } = {}) {
|
|
669
|
-
const sessions = sessionsInScope();
|
|
818
|
+
export async function listEngines({ type = null, subtype = null, backendURL = null } = {}) {
|
|
819
|
+
const sessions = backendURL ? [sessionForBackend(backendURL)] : sessionsInScope();
|
|
670
820
|
const settled = await Promise.allSettled(
|
|
671
821
|
sessions.map((s) => listEnginesForBackend(s.backendURL, { type, subtype })),
|
|
672
822
|
);
|
|
@@ -716,10 +866,10 @@ function sleep(ms) {
|
|
|
716
866
|
// One capability probe per backend per process. Keyed by the RESOLVED backend
|
|
717
867
|
// URL (callers always resolve before probing), so multiple paired backends on
|
|
718
868
|
// mixed versions each get their own answer.
|
|
719
|
-
const
|
|
869
|
+
const backendCapabilitiesCache = new Map();
|
|
720
870
|
|
|
721
|
-
function
|
|
722
|
-
let probe =
|
|
871
|
+
function backendCapabilities(backendURL) {
|
|
872
|
+
let probe = backendCapabilitiesCache.get(backendURL);
|
|
723
873
|
if (!probe) {
|
|
724
874
|
probe = (async () => {
|
|
725
875
|
try {
|
|
@@ -728,26 +878,59 @@ function backendSupportsRenderJobs(backendURL) {
|
|
|
728
878
|
{ timeoutMs: listTimeoutMs() },
|
|
729
879
|
backendURL,
|
|
730
880
|
);
|
|
731
|
-
|
|
732
|
-
return data?.renderJobs === true;
|
|
881
|
+
return (await res.json()) ?? {};
|
|
733
882
|
} catch {
|
|
734
|
-
// Older backends 404 this path (with a non-JSON body) —
|
|
735
|
-
|
|
883
|
+
// Older backends 404 this path (with a non-JSON body) — no
|
|
884
|
+
// capabilities: sync render, no version floor.
|
|
885
|
+
return {};
|
|
736
886
|
}
|
|
737
887
|
})();
|
|
738
|
-
|
|
888
|
+
backendCapabilitiesCache.set(backendURL, probe);
|
|
739
889
|
}
|
|
740
890
|
return probe;
|
|
741
891
|
}
|
|
742
892
|
|
|
893
|
+
async function backendSupportsRenderJobs(backendURL) {
|
|
894
|
+
const caps = await backendCapabilities(backendURL);
|
|
895
|
+
return caps?.renderJobs === true;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Pane-free brand-scoped routes (/api/cli/brands/:brandId/*): design-bundle,
|
|
899
|
+
// translate and render keyed by the brandId the deck's .bundle-ref.json already
|
|
900
|
+
// stores (presFP == brandId), instead of the pane registry — so a bound deck
|
|
901
|
+
// keeps building after every pane key aged out of the add-in's registry (or
|
|
902
|
+
// with PowerPoint closed entirely). Advertised by newer add-ins only.
|
|
903
|
+
export async function backendSupportsBrandRoutes(backendURL) {
|
|
904
|
+
const caps = await backendCapabilities(backendURL);
|
|
905
|
+
return caps?.brandRoutes === true;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// The add-in's hard version floor: /api/cli/capabilities advertises the oldest
|
|
909
|
+
// CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
|
|
910
|
+
// mid-rollout skew otherwise surfaces as confusing route/contract errors deep
|
|
911
|
+
// in a render — and prompt the one actionable fix. No-op when the backend
|
|
912
|
+
// doesn't advertise a floor (older add-ins) or the CLI meets it.
|
|
913
|
+
export async function assertCliVersionSupported(backendURL) {
|
|
914
|
+
const caps = await backendCapabilities(backendURL);
|
|
915
|
+
const min = typeof caps?.minCliVersion === "string" ? caps.minCliVersion : null;
|
|
916
|
+
if (!min || compareCliVersions(cliVersion(), min) >= 0) return;
|
|
917
|
+
const err = new Error(
|
|
918
|
+
`slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
|
|
919
|
+
`supports (${min}). Run \`slidemind update\` (or ` +
|
|
920
|
+
"`npm install -g slidemind@latest`), then retry.",
|
|
921
|
+
);
|
|
922
|
+
err.code = "cli_update_required";
|
|
923
|
+
throw err;
|
|
924
|
+
}
|
|
925
|
+
|
|
743
926
|
// Rebuild a job failure into the exact error shape bridgeFetch throws for the
|
|
744
|
-
// sync route, so callers (etag self-heal,
|
|
927
|
+
// sync route, so callers (etag self-heal, error texts) can't tell the
|
|
745
928
|
// protocols apart.
|
|
746
|
-
function jobFailureError(jobError,
|
|
929
|
+
function jobFailureError(jobError, label) {
|
|
747
930
|
const status = jobError?.status ?? 500;
|
|
748
931
|
const detail = jobError?.error ?? "render_failed";
|
|
749
932
|
const err = new Error(
|
|
750
|
-
`slidemind bridge ${status} render job failed for ${
|
|
933
|
+
`slidemind bridge ${status} render job failed for ${label}: ${detail}`,
|
|
751
934
|
);
|
|
752
935
|
err.status = status;
|
|
753
936
|
err.code = detail;
|
|
@@ -755,9 +938,18 @@ function jobFailureError(jobError, presId) {
|
|
|
755
938
|
return err;
|
|
756
939
|
}
|
|
757
940
|
|
|
758
|
-
|
|
941
|
+
// The render/translate routes exist on two surfaces sharing one contract:
|
|
942
|
+
// /api/cli/presentations/:presId — brandId resolved from the pane registry
|
|
943
|
+
// /api/cli/brands/:brandId — pane-free (bound deck, window optional)
|
|
944
|
+
function renderBasePath({ presId, brandId }) {
|
|
945
|
+
return brandId
|
|
946
|
+
? `/api/cli/brands/${encodeURIComponent(brandId)}`
|
|
947
|
+
: `/api/cli/presentations/${encodeURIComponent(presId)}`;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async function renderViaSync({ basePath, body, backendURL }) {
|
|
759
951
|
const res = await bridgeFetch(
|
|
760
|
-
|
|
952
|
+
`${basePath}/render-pptx`,
|
|
761
953
|
{
|
|
762
954
|
method: "POST",
|
|
763
955
|
headers: { "content-type": "application/json" },
|
|
@@ -774,7 +966,7 @@ async function renderViaSync({ presId, body, backendURL }) {
|
|
|
774
966
|
return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
|
|
775
967
|
}
|
|
776
968
|
|
|
777
|
-
async function renderViaJob({
|
|
969
|
+
async function renderViaJob({ basePath, label, body, backendURL, onProgress }) {
|
|
778
970
|
const pollMs = envInt("SLIDEMIND_BRIDGE_RENDER_POLL_MS", 2500);
|
|
779
971
|
const deadlineMs = envInt("SLIDEMIND_BRIDGE_RENDER_DEADLINE_MS", 900000);
|
|
780
972
|
const resultTimeoutMs = envInt(
|
|
@@ -787,7 +979,7 @@ async function renderViaJob({ presId, body, backendURL, onProgress }) {
|
|
|
787
979
|
// would re-upload a multi-MB slide payload, and creation errors (stale
|
|
788
980
|
// etag, no CD, unknown presId) are definitive.
|
|
789
981
|
const res = await bridgeFetch(
|
|
790
|
-
|
|
982
|
+
`${basePath}/render-jobs`,
|
|
791
983
|
{
|
|
792
984
|
method: "POST",
|
|
793
985
|
headers: { "content-type": "application/json" },
|
|
@@ -832,7 +1024,7 @@ async function renderViaJob({ presId, body, backendURL, onProgress }) {
|
|
|
832
1024
|
// The job record vanished (backend Redis flush/restart) — same recovery
|
|
833
1025
|
// as a lost replica: resubmit once.
|
|
834
1026
|
if (err?.code === "job_not_found") {
|
|
835
|
-
if (resubmitted) throw jobFailureError({ status: 500, error: "job_lost" },
|
|
1027
|
+
if (resubmitted) throw jobFailureError({ status: 500, error: "job_lost" }, label);
|
|
836
1028
|
resubmitted = true;
|
|
837
1029
|
onProgress?.({ state: "restarted" });
|
|
838
1030
|
jobId = await createJob();
|
|
@@ -867,7 +1059,7 @@ async function renderViaJob({ presId, body, backendURL, onProgress }) {
|
|
|
867
1059
|
jobId = await createJob();
|
|
868
1060
|
continue;
|
|
869
1061
|
}
|
|
870
|
-
throw jobFailureError(jobError,
|
|
1062
|
+
throw jobFailureError(jobError, label);
|
|
871
1063
|
}
|
|
872
1064
|
|
|
873
1065
|
if (status.state === "done") {
|
|
@@ -901,8 +1093,15 @@ async function renderViaJob({ presId, body, backendURL, onProgress }) {
|
|
|
901
1093
|
// `slides`: { "<file>.jsx": "<jsx source>" }. `deckAssets`: { "assets/<f>": "<base64>" }.
|
|
902
1094
|
// `onProgress` (optional) receives `{ state, done?, total?, stage? }` ticks on
|
|
903
1095
|
// the job protocol; the sync fallback emits none.
|
|
1096
|
+
//
|
|
1097
|
+
// Target is EXACTLY ONE of:
|
|
1098
|
+
// presId — render via the pane registry (any add-in version)
|
|
1099
|
+
// brandId — render via the pane-free brand route (needs backendURL + a
|
|
1100
|
+
// backend advertising `brandRoutes`); `deckName` labels the render.
|
|
904
1101
|
export async function renderPptx({
|
|
905
|
-
presId,
|
|
1102
|
+
presId = null,
|
|
1103
|
+
brandId = null,
|
|
1104
|
+
deckName = null,
|
|
906
1105
|
slides,
|
|
907
1106
|
deckAssets = null,
|
|
908
1107
|
nativeSlides = null,
|
|
@@ -910,13 +1109,25 @@ export async function renderPptx({
|
|
|
910
1109
|
backendURL = null,
|
|
911
1110
|
onProgress = null,
|
|
912
1111
|
}) {
|
|
913
|
-
if (!presId
|
|
1112
|
+
if (!presId && !brandId) {
|
|
1113
|
+
throw new Error("renderPptx: presId or brandId is required");
|
|
1114
|
+
}
|
|
914
1115
|
const hasSlides = slides && Object.keys(slides).length > 0;
|
|
915
1116
|
const hasNative = nativeSlides && Object.keys(nativeSlides).length > 0;
|
|
916
1117
|
if (!hasSlides && !hasNative) {
|
|
917
1118
|
throw new Error("renderPptx: slides or nativeSlides is required");
|
|
918
1119
|
}
|
|
919
|
-
|
|
1120
|
+
let be = backendURL;
|
|
1121
|
+
if (!be) {
|
|
1122
|
+
if (brandId) throw new Error("renderPptx: backendURL is required with brandId");
|
|
1123
|
+
be = await resolveBackendForPres(presId);
|
|
1124
|
+
}
|
|
1125
|
+
// Version floor first: shipping a whole deck to a backend that no longer
|
|
1126
|
+
// speaks this CLI's contract fails deep and confusingly; fail here instead
|
|
1127
|
+
// with the actionable `slidemind update`.
|
|
1128
|
+
await assertCliVersionSupported(be);
|
|
1129
|
+
const basePath = renderBasePath({ presId, brandId });
|
|
1130
|
+
const label = brandId ? (deckName ?? brandId) : presId;
|
|
920
1131
|
const body = { slides: slides ?? {} };
|
|
921
1132
|
if (deckAssets && Object.keys(deckAssets).length > 0) {
|
|
922
1133
|
body.deckAssets = deckAssets;
|
|
@@ -925,17 +1136,20 @@ export async function renderPptx({
|
|
|
925
1136
|
// server splices them in by numeric-aware sorted filename (contract #7).
|
|
926
1137
|
if (hasNative) body.nativeSlides = nativeSlides;
|
|
927
1138
|
if (etag) body.etag = etag;
|
|
1139
|
+
// The brand surface has no presId to label the render with — pass the deck
|
|
1140
|
+
// slug so job records/temp dirs stay readable (server-side sanitized).
|
|
1141
|
+
if (brandId && deckName) body.deckName = deckName;
|
|
928
1142
|
|
|
929
1143
|
if (await backendSupportsRenderJobs(be)) {
|
|
930
1144
|
try {
|
|
931
|
-
return await renderViaJob({
|
|
1145
|
+
return await renderViaJob({ basePath, label, body, backendURL: be, onProgress });
|
|
932
1146
|
} catch (err) {
|
|
933
1147
|
// The backend speaks the protocol but its job store is down — the sync
|
|
934
1148
|
// route renders in-process without Redis, so fall back to it.
|
|
935
1149
|
if (err?.code !== "jobs_unavailable") throw err;
|
|
936
1150
|
}
|
|
937
1151
|
}
|
|
938
|
-
return renderViaSync({
|
|
1152
|
+
return renderViaSync({ basePath, body, backendURL: be });
|
|
939
1153
|
}
|
|
940
1154
|
|
|
941
1155
|
// Translate a deck the CLI captured LOCALLY (the shipped capture engine) into a
|
|
@@ -943,15 +1157,24 @@ export async function renderPptx({
|
|
|
943
1157
|
// single synchronous request — the server runs only the IP-sensitive boxes→OOXML
|
|
944
1158
|
// half. Returns the same shape as renderPptx ({ ok, base64, slideCount, … }).
|
|
945
1159
|
export async function translateDeck({
|
|
946
|
-
presId,
|
|
1160
|
+
presId = null,
|
|
1161
|
+
brandId = null,
|
|
1162
|
+
deckName = null,
|
|
947
1163
|
captured,
|
|
948
1164
|
nativeSlides = null,
|
|
949
1165
|
etag = null,
|
|
950
1166
|
backendURL = null,
|
|
951
1167
|
}) {
|
|
952
|
-
if (!presId
|
|
1168
|
+
if (!presId && !brandId) {
|
|
1169
|
+
throw new Error("translateDeck: presId or brandId is required");
|
|
1170
|
+
}
|
|
953
1171
|
if (!captured) throw new Error("translateDeck: captured is required");
|
|
954
|
-
|
|
1172
|
+
let be = backendURL;
|
|
1173
|
+
if (!be) {
|
|
1174
|
+
if (brandId) throw new Error("translateDeck: backendURL is required with brandId");
|
|
1175
|
+
be = await resolveBackendForPres(presId);
|
|
1176
|
+
}
|
|
1177
|
+
await assertCliVersionSupported(be);
|
|
955
1178
|
const body = { captured };
|
|
956
1179
|
// Reused native one-slide .pptx (base64), keyed by play-order filename — the
|
|
957
1180
|
// server splices them in alongside the locally-captured JSX slides (contract
|
|
@@ -960,8 +1183,9 @@ export async function translateDeck({
|
|
|
960
1183
|
body.nativeSlides = nativeSlides;
|
|
961
1184
|
}
|
|
962
1185
|
if (etag) body.etag = etag;
|
|
1186
|
+
if (brandId && deckName) body.deckName = deckName;
|
|
963
1187
|
const res = await bridgeFetch(
|
|
964
|
-
|
|
1188
|
+
`${renderBasePath({ presId, brandId })}/translate`,
|
|
965
1189
|
{
|
|
966
1190
|
method: "POST",
|
|
967
1191
|
headers: { "content-type": "application/json" },
|
|
@@ -978,14 +1202,20 @@ export async function translateDeck({
|
|
|
978
1202
|
}
|
|
979
1203
|
|
|
980
1204
|
// Run a pane-relayed engine op; on `pane_suspended` (the server already waited
|
|
981
|
-
// ~25s for a reconnect)
|
|
982
|
-
//
|
|
983
|
-
//
|
|
1205
|
+
// ~25s for a reconnect) OR `pane_timeout` (the pane looked connected but never
|
|
1206
|
+
// acked within the server's 60s window — the server marks it suspended on the
|
|
1207
|
+
// way out, so the same wake applies) wait CLIENT-side for the pane to wake —
|
|
1208
|
+
// one actionable line, then a single retry against the live entry (covers
|
|
1209
|
+
// presId churn too). Engine ops are idempotent reads, and the one mutation
|
|
1210
|
+
// (live insert) rides the SAME operationId through the retry so the pane-side
|
|
1211
|
+
// dedup makes a re-send safe.
|
|
984
1212
|
async function engineOpWithPaneWait(presId, backendURL, attempt) {
|
|
985
1213
|
try {
|
|
986
1214
|
return await attempt(presId, backendURL);
|
|
987
1215
|
} catch (err) {
|
|
988
|
-
if (err?.code !== "pane_suspended"
|
|
1216
|
+
if (err?.code !== "pane_suspended" && err?.code !== "pane_timeout") {
|
|
1217
|
+
throw err;
|
|
1218
|
+
}
|
|
989
1219
|
const live = await waitForPaneReady(presId, {
|
|
990
1220
|
backendURL,
|
|
991
1221
|
onWait: paneWaitNotice,
|
|
@@ -1266,7 +1496,7 @@ export async function viewSlide({
|
|
|
1266
1496
|
}
|
|
1267
1497
|
|
|
1268
1498
|
// Read the CURRENT content of an open presentation (the "pull content" path
|
|
1269
|
-
// behind `slidemind read`
|
|
1499
|
+
// behind `slidemind read`). `mode` selects the
|
|
1270
1500
|
// depth: "summary" returns a per-slide outline (index, layout, title, subtitle);
|
|
1271
1501
|
// "raw" (default) returns each slide's full plain text — NOT the add-in's editing
|
|
1272
1502
|
// markup. Unlike engine ops, content is WINDOW-BOUND: it reads THAT presentation,
|
|
@@ -1356,6 +1586,106 @@ export async function replaceSlides({
|
|
|
1356
1586
|
return ensurePaneAckOk(await res.json().catch(() => ({})), "replace");
|
|
1357
1587
|
}
|
|
1358
1588
|
|
|
1589
|
+
// Non-throwing, PURE (no network) sibling of pickPresentationEntry that answers
|
|
1590
|
+
// the one question `slidemind ready` needs: which corporate design should this
|
|
1591
|
+
// session bind to? Where pickPresentationEntry PROMPTS the user by throwing,
|
|
1592
|
+
// resolveDesignSource returns a STRUCTURED verdict the CLI turns into a bound
|
|
1593
|
+
// bundle, a single blocker, or a "which design?" question — no exceptions as
|
|
1594
|
+
// control flow.
|
|
1595
|
+
//
|
|
1596
|
+
// The bar is RENDER-ready, never pane-ready: the add-in is the source of truth
|
|
1597
|
+
// for the design, and a backgrounded (pane-suspended) window still serves its
|
|
1598
|
+
// bundle and still renders — so it is a valid design source (the caller reports
|
|
1599
|
+
// `paneSuspended` but stays exit 0). Pane readiness only matters later, when a
|
|
1600
|
+
// push/search actually needs the live pane.
|
|
1601
|
+
//
|
|
1602
|
+
// The ONE user-facing ambiguity is ≥2 render-ready windows on DISTINCT designs
|
|
1603
|
+
// (distinct `brandId`). We deliberately do NOT let the deck's own bundle ref
|
|
1604
|
+
// (`deckPresFP`) break that tie — the open add-in wins; a deck never pins a
|
|
1605
|
+
// design preference. The deck ref only:
|
|
1606
|
+
// (a) tie-breaks ONE window mirrored across paired backends (same presId on
|
|
1607
|
+
// dev+prod) via tieBreakMatches — the SAME rule pickPresentationEntry
|
|
1608
|
+
// uses, and its only sanctioned use here; and
|
|
1609
|
+
// (b) surfaces the matching candidate as `suggested`, so the agent can say
|
|
1610
|
+
// "this deck was last built against X" WITHOUT the CLI silently deciding.
|
|
1611
|
+
//
|
|
1612
|
+
// Returns exactly one of:
|
|
1613
|
+
// { status: "none" } — nothing render-ready is open.
|
|
1614
|
+
// { status: "unknown_presid", presId } — an explicit --to names no
|
|
1615
|
+
// render-ready window.
|
|
1616
|
+
// { status: "resolved", entry } — the RAW registry entry (backendURL
|
|
1617
|
+
// intact; the caller strips it before
|
|
1618
|
+
// any user-facing output).
|
|
1619
|
+
// { status: "ambiguous", candidates, suggested }
|
|
1620
|
+
// — candidates deduped to ONE entry per
|
|
1621
|
+
// distinct design; suggested is the
|
|
1622
|
+
// presId of the deck-ref match, else
|
|
1623
|
+
// null.
|
|
1624
|
+
export function resolveDesignSource(
|
|
1625
|
+
registry,
|
|
1626
|
+
{ explicitPresId = null, deckPresFP = null } = {},
|
|
1627
|
+
) {
|
|
1628
|
+
const all = (Array.isArray(registry) ? registry : []).filter(
|
|
1629
|
+
isRenderReadyPresentation,
|
|
1630
|
+
);
|
|
1631
|
+
if (all.length === 0) return { status: "none" };
|
|
1632
|
+
|
|
1633
|
+
// Group by DESIGN. A render-ready pane always carries a brandId, but be
|
|
1634
|
+
// defensive: a missing brandId is treated as its OWN pseudo-design keyed by
|
|
1635
|
+
// presId so two CD-less windows never collapse into one false match.
|
|
1636
|
+
const designKey = (p) =>
|
|
1637
|
+
p.brandId != null && p.brandId !== ""
|
|
1638
|
+
? `brand:${p.brandId}`
|
|
1639
|
+
: `pres:${p.presId}`;
|
|
1640
|
+
|
|
1641
|
+
// Collapse a set of entries to one representative per distinct design,
|
|
1642
|
+
// preferring a live (pane-ready) pane over a suspended mirror, then compute
|
|
1643
|
+
// the deck-ref `suggested` presId over those representatives.
|
|
1644
|
+
const dedupeCandidates = (entries) => {
|
|
1645
|
+
const byKey = new Map();
|
|
1646
|
+
for (const p of entries) {
|
|
1647
|
+
const k = designKey(p);
|
|
1648
|
+
const cur = byKey.get(k);
|
|
1649
|
+
if (!cur || (!isPaneReadyPresentation(cur) && isPaneReadyPresentation(p))) {
|
|
1650
|
+
byKey.set(k, p);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
const candidates = [...byKey.values()];
|
|
1654
|
+
const suggested =
|
|
1655
|
+
(deckPresFP && candidates.find((c) => c.brandId === deckPresFP)?.presId) ||
|
|
1656
|
+
null;
|
|
1657
|
+
return { candidates, suggested };
|
|
1658
|
+
};
|
|
1659
|
+
|
|
1660
|
+
if (explicitPresId) {
|
|
1661
|
+
const matches = all.filter((p) => p.presId === explicitPresId);
|
|
1662
|
+
if (matches.length === 0) {
|
|
1663
|
+
return { status: "unknown_presid", presId: explicitPresId };
|
|
1664
|
+
}
|
|
1665
|
+
if (matches.length === 1) return { status: "resolved", entry: matches[0] };
|
|
1666
|
+
// >1 render-ready match == ONE window mirrored across paired backends.
|
|
1667
|
+
// Tie-break exactly as pickPresentationEntry; only a genuine tie is asked.
|
|
1668
|
+
const winner = tieBreakMatches(matches, deckPresFP);
|
|
1669
|
+
if (winner) return { status: "resolved", entry: winner };
|
|
1670
|
+
return { status: "ambiguous", ...dedupeCandidates(matches) };
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
const distinctDesigns = new Set(all.map(designKey));
|
|
1674
|
+
if (distinctDesigns.size === 1) {
|
|
1675
|
+
// One design open (possibly one window mirrored across backends, or several
|
|
1676
|
+
// windows on the SAME CD). Pick via the mirror tie-break, else a live pane,
|
|
1677
|
+
// else the first — the design is unambiguous either way.
|
|
1678
|
+
const entry =
|
|
1679
|
+
tieBreakMatches(all, deckPresFP) ??
|
|
1680
|
+
all.find(isPaneReadyPresentation) ??
|
|
1681
|
+
all[0];
|
|
1682
|
+
return { status: "resolved", entry };
|
|
1683
|
+
}
|
|
1684
|
+
// ≥2 distinct designs open — the only case that asks the user (the deck ref
|
|
1685
|
+
// does NOT auto-pick; it only annotates `suggested`).
|
|
1686
|
+
return { status: "ambiguous", ...dedupeCandidates(all) };
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1359
1689
|
// Pick a target presentation given the (possibly multi-backend) registry and an
|
|
1360
1690
|
// optional explicit presId. Returns the matched registry ENTRY (which carries
|
|
1361
1691
|
// `backendURL`/`env` after a fan-out list), or throws with a helpful message if
|
|
@@ -1410,7 +1740,7 @@ export function pickPresentationEntry(
|
|
|
1410
1740
|
)
|
|
1411
1741
|
.join("\n");
|
|
1412
1742
|
throw new Error(
|
|
1413
|
-
`Multiple PowerPoint windows are open. Pick one with --to <presId>:\n${list}`,
|
|
1743
|
+
`Multiple PowerPoint windows are open. Pick one with --to <presId>:\n${list}\n${READY_HINT_PICK}`,
|
|
1414
1744
|
);
|
|
1415
1745
|
}
|
|
1416
1746
|
|