slidemind 0.1.0 → 0.1.2

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/bin/slidemind.mjs CHANGED
@@ -60,8 +60,11 @@ function printHelp() {
60
60
  ` --replace delete existing slides first (destructive)\n` +
61
61
  ` --slides <ids> comma-separated source slide ids to push\n` +
62
62
  ` slidemind pair <code> Pair with an add-in (copy the code from its "Connect CLI")\n` +
63
- ` slidemind whoami Show the paired account\n` +
64
- ` slidemind logout Unpair this machine\n` +
63
+ ` --label <name> name this environment (default: derived from URL)\n` +
64
+ ` Pair once per environment; commands fan out across all of them.\n` +
65
+ ` slidemind whoami Show every paired environment (* = default)\n` +
66
+ ` slidemind use <url|env> Set the default environment\n` +
67
+ ` slidemind logout [url|env] Unpair one environment (--all to unpair every one)\n` +
65
68
  ` slidemind init --for <t> Set up slidemind in a harness (registers the MCP server + skill)\n` +
66
69
  ` slidemind uninstall --for <t> Remove a previously-installed skill\n` +
67
70
  ` slidemind update [--for <t>] Fetch the latest release and redeploy\n` +
@@ -72,7 +75,8 @@ function printHelp() {
72
75
  `\n` +
73
76
  `Install targets: claude | codex | opencode | all\n` +
74
77
  `Decks live in ./decks/ relative to your current directory.\n` +
75
- `Override with env: SLIDEMIND_DECKS=/abs/path/to/decks\n`,
78
+ `Override with env: SLIDEMIND_DECKS=/abs/path/to/decks\n` +
79
+ `Global: --backend <url|env> scope a command to one paired environment (also SLIDEMIND_BACKEND)\n`,
76
80
  );
77
81
  }
78
82
 
@@ -221,10 +225,12 @@ async function cmdSearch(rest) {
221
225
  pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
222
226
  );
223
227
  const registry = await client.requireHost();
224
- const presId = client.pickPresentation(registry, to);
228
+ const entry = client.pickPresentationEntry(registry, to);
229
+ const presId = entry.presId;
230
+ const backendURL = entry.backendURL ?? null;
225
231
 
226
232
  if (!engineId) {
227
- const mat = await bundle.materializeBundle(presId);
233
+ const mat = await bundle.materializeBundle(presId, { backendURL });
228
234
  const pick = bundle.pickImageEngine(bundle.readEnginesFromDir(mat.dir));
229
235
  if (!pick) {
230
236
  fail(
@@ -234,7 +240,7 @@ async function cmdSearch(rest) {
234
240
  engineId = pick.id;
235
241
  }
236
242
 
237
- const res = await client.engineSearch({ presId, engineId, query });
243
+ const res = await client.engineSearch({ presId, engineId, query, backendURL });
238
244
  if (asJson) {
239
245
  process.stdout.write(`${JSON.stringify(res, null, 2)}\n`);
240
246
  return;
@@ -279,8 +285,12 @@ async function cmdDownloadBundle(rest) {
279
285
  pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
280
286
  );
281
287
  const registry = await client.requireHost();
282
- const presId = client.pickPresentation(registry, to);
283
- const result = await bundle.materializeBundle(presId, { force });
288
+ const entry = client.pickPresentationEntry(registry, to);
289
+ const presId = entry.presId;
290
+ const result = await bundle.materializeBundle(presId, {
291
+ force,
292
+ backendURL: entry.backendURL ?? null,
293
+ });
284
294
 
285
295
  // Optionally bind a deck to this bundle so build-pptx/push pass the right
286
296
  // etag and the local HTML/editor read the right design files.
@@ -309,7 +319,9 @@ async function cmdPresentations(rest) {
309
319
  );
310
320
  const list = await mod.listOpenPresentations();
311
321
  if (asJson) {
312
- process.stdout.write(JSON.stringify(list, null, 2) + "\n");
322
+ process.stdout.write(
323
+ JSON.stringify(list.map(mod.toPublicPresentation), null, 2) + "\n",
324
+ );
313
325
  return;
314
326
  }
315
327
  if (list.length === 0) {
@@ -320,7 +332,8 @@ async function cmdPresentations(rest) {
320
332
  }
321
333
  for (const p of list) {
322
334
  const label = p.title || p.fileName || "(untitled)";
323
- process.stdout.write(`${p.presId}\t${label}\n`);
335
+ const env = p.env ? `[${p.env}]\t` : "";
336
+ process.stdout.write(`${p.presId}\t${env}${label}\n`);
324
337
  }
325
338
  }
326
339
 
@@ -329,7 +342,17 @@ async function loadBridgeModule(name) {
329
342
  }
330
343
 
331
344
  async function cmdPair(rest) {
332
- const code = rest.find((a) => !a.startsWith("--"));
345
+ let code = null;
346
+ let label = null;
347
+ for (let i = 0; i < rest.length; i++) {
348
+ const a = rest[i];
349
+ if (a === "--label") {
350
+ label = rest[++i];
351
+ if (!label) fail("--label expects a name");
352
+ } else if (!a.startsWith("--") && !code) {
353
+ code = a;
354
+ }
355
+ }
333
356
  if (!code) {
334
357
  fail('usage: slidemind pair <code> (copy the command from the add-in\'s "Connect CLI")');
335
358
  }
@@ -350,54 +373,126 @@ async function cmdPair(rest) {
350
373
  );
351
374
  }
352
375
  const cfg = await loadBridgeModule("config");
353
- cfg.writeSessionConfig({
376
+ const sessions = cfg.upsertSession({
354
377
  backendURL: decoded.backendURL,
355
378
  sessionToken: res.json.sessionToken,
379
+ label,
356
380
  });
381
+ const env = cfg.deriveEnvLabel(decoded.backendURL);
382
+ const others = sessions.length - 1;
383
+ const suffix =
384
+ others > 0
385
+ ? ` (${sessions.length} environments connected — commands now fan out across all of them)`
386
+ : "";
357
387
  process.stdout.write(
358
- `Paired with ${decoded.backendURL}. Run 'slidemind presentations' to verify.\n`,
388
+ `Paired with ${label || env}${suffix}. Run 'slidemind presentations' to verify.\n`,
359
389
  );
360
390
  }
361
391
 
362
392
  async function cmdWhoami() {
363
393
  const cfg = await loadBridgeModule("config");
364
- const session = cfg.readSessionConfig();
365
- if (!session) {
394
+ const sessions = cfg.readSessions();
395
+ if (sessions.length === 0) {
366
396
  process.stderr.write(
367
397
  'Not paired. Run `slidemind pair <code>` using the add-in\'s "Connect CLI".\n',
368
398
  );
369
399
  return;
370
400
  }
371
401
  const pairMod = await loadBridgeModule("pair");
372
- const res = await pairMod.httpJson("GET", `${session.backendURL}/api/cli/whoami`, {
373
- token: session.sessionToken,
374
- });
375
- if (res.status === 200 && res.json) {
376
- process.stdout.write(
377
- `Paired with ${session.backendURL}\n user: ${res.json.oid}\n tenant: ${res.json.tid ?? "(n/a)"}\n`,
402
+ const def = cfg.getDefaultBackend();
403
+ const lines = await Promise.all(
404
+ sessions.map(async (s) => {
405
+ const marker = s.backendURL === def ? "*" : " ";
406
+ let detail;
407
+ try {
408
+ const res = await pairMod.httpJson(
409
+ "GET",
410
+ `${s.backendURL}/api/cli/whoami`,
411
+ { token: s.sessionToken },
412
+ );
413
+ detail =
414
+ res.status === 200 && res.json
415
+ ? `user ${res.json.oid} · tenant ${res.json.tid ?? "(n/a)"}`
416
+ : `session invalid (${res.status}) — re-pair`;
417
+ } catch {
418
+ detail = "unreachable — check your connection or re-pair";
419
+ }
420
+ return `${marker} ${s.label}\n ${detail}`;
421
+ }),
422
+ );
423
+ process.stdout.write(
424
+ `Paired environments (${sessions.length}):\n${lines.join("\n")}\n`,
425
+ );
426
+ }
427
+
428
+ async function cmdLogout(rest) {
429
+ const cfg = await loadBridgeModule("config");
430
+ const all = rest.includes("--all");
431
+ const target = rest.find((a) => !a.startsWith("--")) ?? null;
432
+
433
+ // Which sessions to revoke + remove: all, one (by url/env/label), or the
434
+ // default (the bare `slidemind logout`).
435
+ const sessions = cfg.readSessions();
436
+ if (sessions.length === 0) {
437
+ process.stdout.write("Already logged out.\n");
438
+ return;
439
+ }
440
+ let toRemove;
441
+ if (all) {
442
+ toRemove = sessions;
443
+ } else if (target) {
444
+ toRemove = sessions.filter(
445
+ (s) =>
446
+ s.backendURL === target ||
447
+ s.label === target ||
448
+ cfg.deriveEnvLabel(s.backendURL) === target ||
449
+ s.backendURL.includes(target),
378
450
  );
451
+ if (toRemove.length === 0) {
452
+ fail(`not paired with '${target}'. Run 'slidemind whoami' to list environments.`);
453
+ }
379
454
  } else {
380
- process.stderr.write(
381
- `Session invalid (${res.status}). Re-pair via the add-in's "Connect CLI".\n`,
382
- );
455
+ const def = cfg.getDefaultBackend();
456
+ toRemove = sessions.filter((s) => s.backendURL === def);
383
457
  }
384
- }
385
458
 
386
- async function cmdLogout() {
387
- const cfg = await loadBridgeModule("config");
388
- const session = cfg.readSessionConfig();
389
- if (session) {
459
+ const pairMod = await loadBridgeModule("pair");
460
+ for (const s of toRemove) {
390
461
  try {
391
- const pairMod = await loadBridgeModule("pair");
392
- await pairMod.httpJson("POST", `${session.backendURL}/api/cli/logout`, {
393
- token: session.sessionToken,
462
+ await pairMod.httpJson("POST", `${s.backendURL}/api/cli/logout`, {
463
+ token: s.sessionToken,
394
464
  });
395
465
  } catch {
396
- /* best-effort server-side revoke; we still clear the local session */
466
+ /* best-effort server-side revoke; we still drop the local session */
397
467
  }
468
+ cfg.removeSession(s.backendURL);
398
469
  }
399
- const cleared = cfg.clearSessionConfig();
400
- process.stdout.write(cleared ? "Logged out.\n" : "Already logged out.\n");
470
+ const remaining = cfg.readSessions().length;
471
+ process.stdout.write(
472
+ `Logged out of ${toRemove.map((s) => s.label).join(", ")}.` +
473
+ (remaining > 0 ? ` ${remaining} environment(s) still paired.\n` : "\n"),
474
+ );
475
+ }
476
+
477
+ async function cmdUse(rest) {
478
+ const target = rest.find((a) => !a.startsWith("--")) ?? null;
479
+ if (!target) fail("usage: slidemind use <url|env>");
480
+ const cfg = await loadBridgeModule("config");
481
+ const sessions = cfg.readSessions();
482
+ const match = sessions.find(
483
+ (s) =>
484
+ s.backendURL === target ||
485
+ s.label === target ||
486
+ cfg.deriveEnvLabel(s.backendURL) === target ||
487
+ s.backendURL.includes(target),
488
+ );
489
+ if (!match) {
490
+ fail(`not paired with '${target}'. Run 'slidemind whoami' to list environments.`);
491
+ }
492
+ cfg.setDefaultBackend(match.backendURL);
493
+ process.stdout.write(
494
+ `Default environment set to ${match.label}.\n`,
495
+ );
401
496
  }
402
497
 
403
498
  async function cmdPush(rest) {
@@ -449,6 +544,12 @@ async function cmdPush(rest) {
449
544
  replace,
450
545
  slideIds: slides,
451
546
  });
547
+ for (const w of result.warnings ?? []) {
548
+ process.stderr.write(` warning: ${w}\n`);
549
+ }
550
+ for (const s of result.skippedSlides ?? []) {
551
+ process.stderr.write(` skipped ${s.stem}: ${s.reason}\n`);
552
+ }
452
553
  if (result.paneSuspended) {
453
554
  process.stdout.write(
454
555
  `PowerPoint is backgrounded — the deck rendered but couldn't be pushed.\n` +
@@ -480,7 +581,8 @@ async function cmdNew(rest) {
480
581
  path.join(deckDir, "plan.md"),
481
582
  `# ${name}\n\n` +
482
583
  `Ask your agent (Claude Code / Codex / opencode) to plan and author this deck —\n` +
483
- `the slidemind-create-slides skill fills this in, then writes slides under ./slides/.\n`,
584
+ `the slidemind-create-slides skill fills this in, then writes slides under ./slides/\n` +
585
+ `as NN-<topic>.jsx (zero-padded prefix — slide order is filename order).\n`,
484
586
  );
485
587
  process.stdout.write(`Created ${path.relative(process.cwd(), deckDir)}\n`);
486
588
  process.stdout.write(
@@ -544,13 +646,27 @@ async function cmdUninstall(rest) {
544
646
  ui.done(target === "all" ? "removed from all harnesses" : `removed from ${target}`);
545
647
  }
546
648
 
649
+ // On Windows we must spawn through the shell (Node refuses to exec .cmd
650
+ // shims directly since CVE-2024-27980), and `shell: true` joins file + args
651
+ // into one cmd.exe command string WITHOUT quoting — so an unquoted
652
+ // `C:\Program Files\nodejs\npm.cmd` splits at the space and cmd tries to run
653
+ // `C:\Program`. Quote anything containing whitespace before handing it over.
654
+ function cmdQuote(s) {
655
+ return /\s/.test(s) ? `"${s}"` : s;
656
+ }
657
+
547
658
  function spawnInherit(file, args) {
659
+ const useShell = process.platform === "win32";
548
660
  return new Promise((resolve, reject) => {
549
- const child = spawn(file, args, {
550
- stdio: "inherit",
551
- env: process.env,
552
- shell: process.platform === "win32",
553
- });
661
+ const child = spawn(
662
+ useShell ? cmdQuote(file) : file,
663
+ useShell ? args.map(cmdQuote) : args,
664
+ {
665
+ stdio: "inherit",
666
+ env: process.env,
667
+ shell: useShell,
668
+ },
669
+ );
554
670
  child.on("error", reject);
555
671
  child.on("exit", (code) => {
556
672
  if (code === 0) resolve();
@@ -625,7 +741,26 @@ async function cmdUpdate(rest) {
625
741
 
626
742
  // ---------- main ----------
627
743
 
628
- async function main(argv) {
744
+ // Pull a global `--backend <url|env>` out of argv (it may appear anywhere) and
745
+ // expose it as SLIDEMIND_BACKEND, which scopes the fan-out across paired
746
+ // backends down to one — for speed or to disambiguate a presId that's open in
747
+ // more than one environment. Returns argv with the flag (and its value) removed.
748
+ function extractBackendScope(argv) {
749
+ const out = [];
750
+ for (let i = 0; i < argv.length; i++) {
751
+ if (argv[i] === "--backend") {
752
+ const val = argv[++i];
753
+ if (!val) fail("--backend expects a url or env name");
754
+ process.env.SLIDEMIND_BACKEND = val;
755
+ } else {
756
+ out.push(argv[i]);
757
+ }
758
+ }
759
+ return out;
760
+ }
761
+
762
+ async function main(rawArgv) {
763
+ const argv = extractBackendScope(rawArgv);
629
764
  const [cmd, ...rest] = argv;
630
765
  if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
631
766
  printHelp();
@@ -660,6 +795,9 @@ async function main(argv) {
660
795
  case "logout":
661
796
  await cmdLogout(rest);
662
797
  break;
798
+ case "use":
799
+ await cmdUse(rest);
800
+ break;
663
801
  case "presentations":
664
802
  await cmdPresentations(rest);
665
803
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidemind",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Connect Claude Code, Codex & opencode to the slidemind PowerPoint add-in — let your coding agent build and edit native, on-brand PowerPoint decks against your active corporate design, from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "build-pptx": "node bin/slidemind.mjs build-pptx",
28
28
  "lint:parity": "node scripts/check-skill-parity.mjs",
29
29
  "release": "node scripts/release.mjs",
30
- "test": "node --test test/*.test.mjs"
30
+ "test": "node --test"
31
31
  },
32
32
  "dependencies": {
33
33
  "@babel/parser": "^7.26.5",
@@ -18,10 +18,10 @@ So this file owns only what is genuinely CLI-specific: the **host precondition**
18
18
  This skill drives the `slidemind` CLI + its MCP server (`slidemind_list_open_presentations`, `slidemind_push_deck`, `slidemind_search_engine`, `slidemind_download_result`). The CLI reaches PowerPoint through the add-in's **cloud relay** — it doesn't run any local server. Patch what's missing yourself, but the **pairing** step needs the user once (they hold the add-in's identity):
19
19
 
20
20
  - **CLI missing** (`slidemind` not on `PATH`, or the `slidemind_*` MCP tools absent): `npm install -g slidemind`, then `slidemind init --for <claude|codex|opencode|all>` (registers the MCP server + (re)deploys this skill). The harness may need a restart for the `slidemind_*` tools to appear.
21
- - **Not paired** (a command says *slidemind is not paired*): this is the one step you can't do for the user. Tell them: in the slidemind add-in click **"Connect CLI"**, then paste the shown `slidemind pair …` command into this terminal. It's one-time per machine (the code carries the backend URL + a session token — no hardcoded URLs, no separate login; the add-in's own session is the auth). `slidemind whoami` confirms the pairing.
21
+ - **Not paired** (a command says *slidemind is not paired*): this is the one step you can't do for the user. Tell them: in the slidemind add-in click **"Connect CLI"**, then paste the shown `slidemind pair …` command into this terminal. It's one-time per environment (the code carries that backend's URL + a session token — no hardcoded URLs, no separate login; the add-in's own session is the auth). `slidemind whoami` lists every paired environment.
22
22
  - `npx slidemind <command>` runs one-off without a global install; `slidemind --version` confirms PATH; `slidemind update` upgrades (`--ref <version>` to pin).
23
23
 
24
- After pairing once: **CLI paired + the add-in open ≡ it just works**, against whatever environment (dev / prod / a client deployment) the add-in is wired to.
24
+ After pairing once: **CLI paired + the add-in open ≡ it just works**, against whatever environment (dev / prod / a client deployment) the add-in is wired to. The CLI is **multi-environment**: pair once per backend (local / dev / prod) and every command fans out across all of them, tags each open window with its `env`, and routes to the right backend automatically — no manual switching. (Scope to one with `--backend <url|env>` when you need to.)
25
25
 
26
26
  ## Precondition gate (check this first, every time)
27
27
 
@@ -55,7 +55,7 @@ Keep planning about intent and content. Don't materialize the bundle or read bra
55
55
  - `corporate-design.css`, `layouts/*.jsx`, `layouts/layouts.css`, `assets/`, `samples/` (the brand's own reference slides), `engines.json`.
56
56
  - `workflow/` — the add-in-owned authoring craft: `create.md`, `edit.md`, `composition.md`, `layouts.md`, `authoring.md`, `build-and-export.md`.
57
57
  3. **Study brand samples (before composing).** The materialized bundle's `samples/` dir holds the brand's own reference slides. If it's non-empty, pick **3 at random** and **Read** each (the Read tool renders images) *before* writing any JSX — this is the local-harness stand-in for the in-app persona's reference thumbnails, and it is what keeps a deck looking on-brand rather than merely compliant. Study them for whitespace rhythm, shape density, hierarchy, accent use, and layering, then compose in that spirit (never copy a sample 1:1). This is `composition.md`'s samples discipline; skip only when `samples/` is empty.
58
- 4. **Read, then author.** Read the four create-stage references together — `workflow/create.md` (the procedure), `workflow/composition.md` (the composition baseline), `workflow/layouts.md` (palette + working area), and `workflow/authoring.md` (the substrate rules) — plus the deck's `design.md` and the specific `layouts/*.jsx` the plan uses. (That is the read set `workflow/create.md` → Context loading prescribes; follow it as written.) Then write `decks/<slug>/slides/*.jsx` — one default export per slide, composing one bundle layout (Mode A) or a blank-content layout with freeform children (Mode B), exactly as those docs prescribe.
58
+ 4. **Read, then author.** Read the four create-stage references together — `workflow/create.md` (the procedure), `workflow/composition.md` (the composition baseline), `workflow/layouts.md` (palette + working area), and `workflow/authoring.md` (the substrate rules) — plus the deck's `design.md` and the specific `layouts/*.jsx` the plan uses. (That is the read set `workflow/create.md` → Context loading prescribes; follow it as written.) Then write `decks/<slug>/slides/NN-<topic>.jsx` — zero-padded numeric prefix from the first file on (play order is filename order here; see the slide-file row in the command mapping), one default export per slide, composing one bundle layout (Mode A) or a blank-content layout with freeform children (Mode B), exactly as those docs prescribe.
59
59
  5. **Imagery from the engine.** Per the `### Brand Image Sources` block in `design.md`, search the brand engine and place results (see the command mapping — this is `engine_search`/`engine_add` in the workflow docs).
60
60
  6. **Render / push.** `slidemind push <deck>` (into PowerPoint) or `slidemind build-pptx <deck>` (to a file) — both render server-side. This is `build_and_push` in `workflow/build-and-export.md`. If several presentations are open, confirm **which one to push into** first (it can differ from the CD source). **Run the push without asking otherwise**; it should work while PowerPoint is backgrounded. If it times out waiting for a true task-pane reconnect, ask the user to click the slidemind task pane once and re-run `push` without rebuilding.
61
61
  7. **Sanity pass.** Run the checks in `workflow/create.md` before reporting done.
@@ -79,7 +79,7 @@ The bundle's `workflow/*.md` are written for the in-app design agent and name **
79
79
  | `build_and_push` | `slidemind push <deck>` (into the open PowerPoint) · `slidemind build-pptx <deck>` (to a `.pptx` file) — both render server-side via the bridge |
80
80
  | `engine_search` | MCP `slidemind_search_engine({ presentation_id, query })` → returns a `searchId` + numbered results |
81
81
  | `engine_add` / `insert_image` | MCP `slidemind_download_result({ presentation_id, deck_path, search_id, selection, icon_color? })` — or batch many at once with `selections: [{ selection, icon_color? }, …]`. Writes each asset into `decks/<slug>/assets/`, returns a `./assets/<file>` reference for `<img src=...>` plus a `recolor` descriptor (see *Recolouring engine icons*) |
82
- | writing `slides/<topic>.jsx` in the chat workspace | write files under `decks/<slug>/slides/*.jsx` on disk |
82
+ | writing `slides/<topic>.jsx` in the chat workspace — unprefixed kebab-case names; play order = the ordered list passed to `build_and_push` | write `decks/<slug>/slides/NN-<topic>.jsx` on disk with a **zero-padded numeric prefix** (`01-cover.jsx`, `02-agenda.jsx`, …) — over the CLI play order is **numeric-aware filename order**; there is no ordered-list channel, so reordering = renaming files. The bundle's "don't pad with index prefixes" rule is in-app-only; here it inverts |
83
83
  | reading workspace files (`design.md`, `layouts/`, `workflow/*`) | read them from the materialized bundle dir (`<bundle>/…`) and your `decks/<slug>/` |
84
84
  | reference-slide thumbnails injected at `add_slide` (in-app persona) | **Read** 3 random images from the materialized bundle's `samples/` dir — the Read tool renders them (see *The create flow* step 3) |
85
85
  | `present_plan_for_review` (manager-owned) | **you** run intake → plan with the user (above); persist `decks/<slug>/plan.md` for decks |
@@ -14,7 +14,7 @@ import { randomUUID } from "node:crypto";
14
14
 
15
15
  import {
16
16
  insertSlides,
17
- pickPresentation,
17
+ pickPresentationEntry,
18
18
  renderPptx,
19
19
  replaceSlides,
20
20
  requireHost,
@@ -24,16 +24,16 @@ import {
24
24
  readDeckBundleRef,
25
25
  writeDeckBundleRef,
26
26
  } from "./bundle.mjs";
27
+ import { slideOrderWarning, sortSlideFilenames } from "../lib/slide-order.mjs";
27
28
 
28
29
  function gatherSlides(deckDir) {
29
30
  const slidesDir = path.join(deckDir, "slides");
30
31
  if (!fs.existsSync(slidesDir) || !fs.statSync(slidesDir).isDirectory()) {
31
32
  throw new Error(`Expected 'slides/' folder in ${deckDir}`);
32
33
  }
33
- const files = fs
34
- .readdirSync(slidesDir)
35
- .filter((f) => f.endsWith(".jsx"))
36
- .sort();
34
+ const files = sortSlideFilenames(
35
+ fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx")),
36
+ );
37
37
  if (files.length === 0) {
38
38
  throw new Error(`No .jsx slides under ${slidesDir}`);
39
39
  }
@@ -41,7 +41,7 @@ function gatherSlides(deckDir) {
41
41
  for (const f of files) {
42
42
  slides[f] = fs.readFileSync(path.join(slidesDir, f), "utf8");
43
43
  }
44
- return slides;
44
+ return { slides, orderWarning: slideOrderWarning(files) };
45
45
  }
46
46
 
47
47
  // Collect deck-local assets (icons the agent downloaded, deck photos) as
@@ -68,8 +68,8 @@ function gatherDeckAssets(deckDir) {
68
68
 
69
69
  // Render a deck to base64 .pptx over the bridge. Pass the deck's authored-against
70
70
  // bundle etag (when recorded) so the server can detect CD skew.
71
- export async function renderDeckToBase64(deckDir, { presId }) {
72
- const slides = gatherSlides(deckDir);
71
+ export async function renderDeckToBase64(deckDir, { presId, backendURL = null }) {
72
+ const { slides, orderWarning } = gatherSlides(deckDir);
73
73
  const deckAssets = gatherDeckAssets(deckDir);
74
74
  // Bind the deck to a materialized bundle if it isn't already. Without a
75
75
  // `.bundle-ref.json` the etag below is null (server-side skew detection
@@ -79,7 +79,7 @@ export async function renderDeckToBase64(deckDir, { presId }) {
79
79
  let ref = readDeckBundleRef(deckDir);
80
80
  if (!ref?.etag) {
81
81
  try {
82
- const mat = await materializeBundle(presId);
82
+ const mat = await materializeBundle(presId, { backendURL });
83
83
  writeDeckBundleRef(deckDir, { presFP: mat.presFP, etag: mat.etag });
84
84
  ref = readDeckBundleRef(deckDir);
85
85
  } catch {
@@ -93,10 +93,14 @@ export async function renderDeckToBase64(deckDir, { presId }) {
93
93
  slides,
94
94
  deckAssets,
95
95
  etag: ref?.etag ?? null,
96
+ backendURL,
96
97
  });
97
98
  if (!result?.base64) {
98
99
  throw new Error("render-pptx returned no pptx bytes");
99
100
  }
101
+ if (orderWarning) {
102
+ result.warnings = [orderWarning, ...(result.warnings ?? [])];
103
+ }
100
104
  return result; // { ok, base64, slideCount, warnings, skippedSlides }
101
105
  }
102
106
 
@@ -122,9 +126,11 @@ export async function buildAndPush({
122
126
  }
123
127
 
124
128
  const registry = await requireHost();
125
- const presId = pickPresentation(registry, presentationId);
129
+ const entry = pickPresentationEntry(registry, presentationId);
130
+ const presId = entry.presId;
131
+ const backendURL = entry.backendURL ?? null;
126
132
 
127
- const result = await renderDeckToBase64(deckDir, { presId });
133
+ const result = await renderDeckToBase64(deckDir, { presId, backendURL });
128
134
  const base64 = result.base64;
129
135
  const bytes = Buffer.byteLength(base64, "base64");
130
136
  const mode = replace ? "replace" : "insert";
@@ -132,13 +138,14 @@ export async function buildAndPush({
132
138
 
133
139
  const doPush = () =>
134
140
  replace
135
- ? replaceSlides({ presId, base64, operationId })
141
+ ? replaceSlides({ presId, base64, operationId, backendURL })
136
142
  : insertSlides({
137
143
  presId,
138
144
  base64,
139
145
  insertAt,
140
146
  sourceSlideIds: slideIds,
141
147
  operationId,
148
+ backendURL,
142
149
  });
143
150
 
144
151
  const resp = await doPush();
@@ -162,8 +169,12 @@ export async function buildPptxFile({
162
169
  }) {
163
170
  if (!deckDir) throw new Error("buildPptxFile: deckDir is required");
164
171
  const registry = await requireHost();
165
- const presId = pickPresentation(registry, presentationId);
166
- const result = await renderDeckToBase64(deckDir, { presId });
172
+ const entry = pickPresentationEntry(registry, presentationId);
173
+ const presId = entry.presId;
174
+ const result = await renderDeckToBase64(deckDir, {
175
+ presId,
176
+ backendURL: entry.backendURL ?? null,
177
+ });
167
178
  const out =
168
179
  outPath || path.join(deckDir, `${path.basename(deckDir)}.pptx`);
169
180
  fs.writeFileSync(out, Buffer.from(result.base64, "base64"));
@@ -8,6 +8,14 @@
8
8
  // the presentation fingerprint (`presFP`) so multiple decks sharing a CD share
9
9
  // one materialized bundle.
10
10
  //
11
+ // NOTE (multi-backend): with the CLI paired to several backends (local/dev/prod)
12
+ // the cache is shared across them and keyed ONLY by `presFP`. That assumes a
13
+ // backend contract: `presFP` must be globally unique across deployments (it is a
14
+ // corporate-design fingerprint, deliberately shared across decks/presentations
15
+ // on the same CD — so it must NOT be keyed by backendURL, which would break that
16
+ // sharing). If two backends ever mint the same presFP for different content,
17
+ // they would collide here.
18
+ //
11
19
  // ${XDG_CACHE_HOME:-~/.cache}/slidemind/bundles/<presFP>/
12
20
  // ├── corporate-design.css (from wire.css)
13
21
  // ├── design.md (from wire.designRules)
@@ -120,8 +128,11 @@ function sampleExt(contentType) {
120
128
  // Fetch + write the bundle for `presId`. Returns { dir, presFP, etag, version,
121
129
  // cached }. Skips the re-download when the cached bundle's etag already matches
122
130
  // (unless `force`).
123
- export async function materializeBundle(presId, { force = false } = {}) {
124
- const wire = await getDesignBundle(presId);
131
+ export async function materializeBundle(
132
+ presId,
133
+ { force = false, backendURL = null } = {},
134
+ ) {
135
+ const wire = await getDesignBundle(presId, { backendURL });
125
136
  if (!wire || !wire.presFP) {
126
137
  throw new Error("design-bundle response is missing presFP");
127
138
  }