slidemind 0.2.3 → 0.3.0
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 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +65 -15
- package/README.md +36 -2
- package/bin/slidemind.mjs +1609 -225
- package/install.ps1 +70 -0
- package/install.sh +65 -0
- package/package.json +3 -1
- package/skills/create-slides/SKILL.md +47 -19
- package/skills/search/SKILL.md +26 -18
- package/src/bridge/build-and-push.mjs +431 -38
- package/src/bridge/capture.mjs +12 -1
- package/src/bridge/client.mjs +1214 -107
- package/src/bridge/config.mjs +193 -1
- package/src/bridge/engine-download.mjs +61 -8
- package/src/bridge/graph-auth.mjs +859 -0
- package/src/bridge/http-json.mjs +15 -6
- package/src/bridge/secrets.mjs +75 -0
- package/src/bridge/setup-claim.mjs +6 -0
- package/src/builders/build.mjs +3 -1
- package/src/builders/gate-server.mjs +156 -0
- package/src/builders/gate-session.js +355 -0
- package/src/builders/plan-overlay.js +507 -225
- package/src/builders/plan.mjs +169 -98
- package/src/builders/resolve-skill.mjs +64 -2
- package/src/builders/review-overlay.js +390 -302
- package/src/builders/review.mjs +136 -50
- package/src/lib/adopt.mjs +407 -0
- package/src/lib/conversions.mjs +263 -0
- package/src/lib/overwrite-guard.mjs +91 -0
- package/src/lib/reconcile-ack.mjs +46 -0
- package/src/lib/reconcile.mjs +306 -0
- package/src/lib/slide-guids.mjs +143 -0
- package/src/pptx/fill/engine.mjs +181 -135
- package/src/pptx/hidden-ole.mjs +22 -1
- package/src/pptx/slide-swap.mjs +23 -1
- package/src/pptx/tags.mjs +676 -0
package/bin/slidemind.mjs
CHANGED
|
@@ -29,12 +29,19 @@ import os from "node:os";
|
|
|
29
29
|
import path from "node:path";
|
|
30
30
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
31
31
|
|
|
32
|
-
// The
|
|
33
|
-
//
|
|
32
|
+
// The eager `src/` imports. Everything else is lazy-loaded for startup speed;
|
|
33
|
+
// these cannot be. `install-channel` because the version-floor ladder below is
|
|
34
|
+
// sync; `secrets` because it wraps the output streams before the first line can
|
|
35
|
+
// be written — a scrubber installed after the leak is not a scrubber; and the
|
|
36
|
+
// env-label helpers because they are called from string builders all over this
|
|
37
|
+
// file, most of which have no `cfg` handle. All three are node-builtins only
|
|
38
|
+
// (fs / os / path / crypto / child_process), so none of them costs startup.
|
|
34
39
|
import {
|
|
35
40
|
isClaudePluginInstall,
|
|
36
41
|
updateCliInstruction,
|
|
37
42
|
} from "../src/install-channel.mjs";
|
|
43
|
+
import { register as registerSecret, scrub } from "../src/bridge/secrets.mjs";
|
|
44
|
+
import { envPhrase, envTag, isMultiEnv } from "../src/bridge/config.mjs";
|
|
38
45
|
|
|
39
46
|
const __filename = fileURLToPath(import.meta.url);
|
|
40
47
|
const __dirname = path.dirname(__filename);
|
|
@@ -63,12 +70,15 @@ function printHelp() {
|
|
|
63
70
|
` coding agent found on this machine; none to skip)\n` +
|
|
64
71
|
` --no-install skip the global npm install (already installed)\n` +
|
|
65
72
|
` --no-ready skip the closing readiness check\n` +
|
|
73
|
+
` --no-login skip the Microsoft 365 browser sign-in offer\n` +
|
|
66
74
|
` --json machine-readable result\n` +
|
|
67
75
|
` slidemind ready [deck] One-command session setup — run this FIRST each session.\n` +
|
|
68
76
|
` Verifies authorization/connection/CLI version, resolves the open\n` +
|
|
69
77
|
` design, materializes its bundle, and (with a deck) binds it.\n` +
|
|
70
78
|
` Exit 0 = ready; non-zero prints one actionable blocker.\n` +
|
|
79
|
+
` A deck already bound to a design readies with PowerPoint closed.\n` +
|
|
71
80
|
` --to <presId> bind to a specific open window\n` +
|
|
81
|
+
` --brand <id> bind to a design by id (presFP) — no window needed\n` +
|
|
72
82
|
` --force re-download the bundle even if current\n` +
|
|
73
83
|
` --connect-only check the connection only (no design/bundle)\n` +
|
|
74
84
|
` --json machine-readable readiness payload\n` +
|
|
@@ -80,7 +90,8 @@ function printHelp() {
|
|
|
80
90
|
` blocks until the user decides, prints DECISION: approved | comments\n` +
|
|
81
91
|
` --port <n> bind a fixed port (default: ephemeral)\n` +
|
|
82
92
|
` slidemind concepts <deck> After plan sign-off, generate one concept mockup PNG per planned\n` +
|
|
83
|
-
` slide into <deck>/concepts/concept-slide-NN.png (
|
|
93
|
+
` slide into <deck>/concepts/concept-slide-NN.png (bound deck,\n` +
|
|
94
|
+
` pane-free — no PowerPoint window needed).\n` +
|
|
84
95
|
` Read each as a design target, then rebuild it as slide JSX.\n` +
|
|
85
96
|
` Optional aid — skips cleanly (exit 0) if unavailable.\n` +
|
|
86
97
|
` slidemind build <deck> Build self-contained HTML\n` +
|
|
@@ -88,31 +99,47 @@ function printHelp() {
|
|
|
88
99
|
` approves or leaves comments (host-free)\n` +
|
|
89
100
|
` --port <n> bind a fixed port (default: ephemeral)\n` +
|
|
90
101
|
` slidemind build-pptx <deck> Export native .pptx — via the add-in for compose decks (host), LOCALLY for template packs (host-free)\n` +
|
|
102
|
+
` --pack <dir> explicit template-pack directory (plugin integrations)\n` +
|
|
91
103
|
` --to <presId> target a specific window (required if >1 open)\n` +
|
|
92
104
|
` --out <path> output path (default <deck>/<deck>.pptx)\n` +
|
|
105
|
+
` --force discard manual PowerPoint edits instead of reconciling them\n` +
|
|
106
|
+
` slidemind inspect-template-deck <deck> Inspect a template deck as JSON for pack-owned validation\n` +
|
|
107
|
+
` --pack <dir> explicit template-pack directory (required)\n` +
|
|
108
|
+
` --allow-unrendered-freeform include JSX inserts without .pptx artifacts\n` +
|
|
109
|
+
` --json print the normalized deck model\n` +
|
|
93
110
|
` slidemind view-draft <deck> Render the deck's slides to per-slide PNGs (no push, no .pptx)\n` +
|
|
94
111
|
` so the agent can look before review; host-free once the\n` +
|
|
95
112
|
` bundle is materialized\n` +
|
|
96
113
|
` --to <presId> window to fetch the bundle from (only if not cached)\n` +
|
|
97
114
|
` --out <dir> output dir (default <deck>/.draft-preview/)\n` +
|
|
98
115
|
` --open open each rendered PNG\n` +
|
|
99
|
-
` slidemind search "<query>" Search an engine or scope for images or slides (
|
|
116
|
+
` slidemind search "<query>" Search an engine or scope for images or slides (no window needed on\n` +
|
|
117
|
+
` add-ins that serve the pane-free engine routes; older ones relay\n` +
|
|
118
|
+
` through an open host)\n` +
|
|
100
119
|
` --engine <id> engine, or scope:all | scope:sharepoint to search unfiltered\n` +
|
|
101
120
|
` (default: first image engine; scope:all for slide intents)\n` +
|
|
102
121
|
` --result-type <t> slide | image — required for a scope:* target\n` +
|
|
103
122
|
` --intent <i> auto | content | visual (slide engines)\n` +
|
|
104
123
|
` --top <N> max results\n` +
|
|
105
|
-
` --
|
|
106
|
-
` --
|
|
107
|
-
`
|
|
124
|
+
` --deck <deck> route by this deck's bound design (multi-environment)\n` +
|
|
125
|
+
` --to <presId> pin a window on the relayed path (ignored pane-free)\n` +
|
|
126
|
+
` --json print the raw result payload (carries the searchId)\n` +
|
|
127
|
+
` slidemind view-slide [opts] Fetch a slide search result's preview image to a file\n` +
|
|
108
128
|
` --search <id> searchId from 'slidemind search'\n` +
|
|
109
129
|
` --pick <n> 1-based selection number to view\n` +
|
|
110
|
-
` --to <presId> the window the search ran on (
|
|
130
|
+
` --to <presId> the window the search ran on (relayed path only)\n` +
|
|
111
131
|
` --out <dir> output dir (default: a slidemind temp dir)\n` +
|
|
112
|
-
` slidemind
|
|
132
|
+
` slidemind read-source [opts] Read the ORIGINAL deck a slide search result came from\n` +
|
|
133
|
+
` --search <id> searchId from 'slidemind search'\n` +
|
|
134
|
+
` --pick <n> 1-based selection number\n` +
|
|
135
|
+
` --from-slide <n> first slide to read (1-based)\n` +
|
|
136
|
+
` --to-slide <n> last slide to read (1-based)\n` +
|
|
137
|
+
` --to <presId> the window the search ran on (relayed path only)\n` +
|
|
138
|
+
` --out <file> write the Markdown to a file instead of stdout\n` +
|
|
139
|
+
` slidemind ls engines List the live engine catalog + search scopes\n` +
|
|
113
140
|
` --type <t> collection | search | scope\n` +
|
|
114
141
|
` --subtype <s> image | slide | template | content\n` +
|
|
115
|
-
` --to <presId> target a specific window/backend\n` +
|
|
142
|
+
` --to <presId> target a specific window/backend (relayed path only)\n` +
|
|
116
143
|
` --all include every paired backend\n` +
|
|
117
144
|
` --json print the raw engine list\n` +
|
|
118
145
|
` slidemind insert-slide [opts] Insert reused slide search results live into a window (requires a host)\n` +
|
|
@@ -121,17 +148,23 @@ function printHelp() {
|
|
|
121
148
|
` --to <presId> target window (required if >1 open)\n` +
|
|
122
149
|
` --at <N> insert after slide N (0-based); default = append\n` +
|
|
123
150
|
` slidemind add-slide [opts] Fold reused slide(s) into a deck as native .pptx\n` +
|
|
124
|
-
` --search <id> searchId from 'slidemind search'
|
|
151
|
+
` --search <id> searchId from 'slidemind search'\n` +
|
|
125
152
|
` --from <file.pptx> fold slide(s) from a local .pptx instead (host-free)\n` +
|
|
126
153
|
` --pick <n,..> 1-based selection number(s)\n` +
|
|
127
154
|
` --deck <deck> deck to add the slides to\n` +
|
|
128
|
-
` --to <presId>
|
|
155
|
+
` --to <presId> the window the search ran on (relayed path only; ignored with --from)\n` +
|
|
129
156
|
` --at <N> numeric prefix to start at (default: append)\n` +
|
|
130
|
-
`
|
|
157
|
+
` --json machine-readable result (carries the searchId)\n` +
|
|
158
|
+
` slidemind adopt <file.pptx> Rebuild a deck workspace from a file's embedded source; foreign\n` +
|
|
159
|
+
` slides are converted to editable jsx where the add-in offers it\n` +
|
|
160
|
+
` --deck <slug> deck folder to write (default: the file's name)\n` +
|
|
161
|
+
` --brand <presFP> design to convert against (when the file names none)\n` +
|
|
162
|
+
` --no-convert fold every untagged slide as native .pptx instead\n` +
|
|
163
|
+
` slidemind download-result [opts] Download image/icon search result(s) into a deck's assets/\n` +
|
|
131
164
|
` --search <id> searchId from 'slidemind search'\n` +
|
|
132
165
|
` --pick <n,..> 1-based selection number(s)\n` +
|
|
133
166
|
` --deck <deck> deck to write the asset(s) into\n` +
|
|
134
|
-
` --to <presId> the window the search ran on (
|
|
167
|
+
` --to <presId> the window the search ran on (relayed path only)\n` +
|
|
135
168
|
` --icon-color <hex[,hex,..]> bake a colour into SVG result(s) (one for all, or one per --pick)\n` +
|
|
136
169
|
` slidemind imagen "<prompt>" Generate ONE image with the add-in's image model into a deck's assets/ (paired backend, pane-free)\n` +
|
|
137
170
|
` --deck <deck> bound deck to write the image into (required)\n` +
|
|
@@ -144,7 +177,9 @@ function printHelp() {
|
|
|
144
177
|
` --force re-download even if the cached bundle is current\n` +
|
|
145
178
|
` slidemind presentations List active PowerPoint panes registered with the add-in\n` +
|
|
146
179
|
` --all include retained suspended entries\n` +
|
|
147
|
-
` slidemind doctor Diagnose
|
|
180
|
+
` slidemind doctor Diagnose two independent ladders: backend authoring readiness\n` +
|
|
181
|
+
` (session, backend, CLI version, engine routes) and live\n` +
|
|
182
|
+
` PowerPoint readiness (windows, pane)\n` +
|
|
148
183
|
` --json print machine-readable status\n` +
|
|
149
184
|
` slidemind read [presId|file.pptx] Pull the content of an open presentation (requires a host),\n` +
|
|
150
185
|
` or of a local .pptx path (read host-free from disk)\n` +
|
|
@@ -156,6 +191,11 @@ function printHelp() {
|
|
|
156
191
|
` --at <index> insert after slide N (0-based); default = append\n` +
|
|
157
192
|
` --replace delete existing slides first (destructive)\n` +
|
|
158
193
|
` --slides <ids> comma-separated source slide ids to push\n` +
|
|
194
|
+
` --accept-conversions replace even when a converted slide is unconfirmed\n` +
|
|
195
|
+
` --force discard manual PowerPoint edits instead of reconciling them\n` +
|
|
196
|
+
` slidemind login Sign in to Microsoft 365 in your browser so SharePoint-backed\n` +
|
|
197
|
+
` slides and images download without an open PowerPoint window\n` +
|
|
198
|
+
` --backend <url|env> environment to sign in for (default: the default one)\n` +
|
|
159
199
|
` slidemind whoami Show every connected environment (* = default)\n` +
|
|
160
200
|
` slidemind use <url|env> Set the default environment\n` +
|
|
161
201
|
` slidemind logout [url|env] Unpair one environment (--all to unpair every one)\n` +
|
|
@@ -429,15 +469,89 @@ async function cmdImagen(rest) {
|
|
|
429
469
|
);
|
|
430
470
|
}
|
|
431
471
|
|
|
472
|
+
async function cmdInspectTemplateDeck(rest) {
|
|
473
|
+
const { deck, passthrough } = splitDeckArg(rest, "inspect-template-deck");
|
|
474
|
+
let packDir = null;
|
|
475
|
+
let json = false;
|
|
476
|
+
let allowUnrenderedFreeform = false;
|
|
477
|
+
for (let i = 0; i < passthrough.length; i++) {
|
|
478
|
+
const a = passthrough[i];
|
|
479
|
+
if (a === "--pack") {
|
|
480
|
+
packDir = passthrough[++i];
|
|
481
|
+
if (!packDir) fail("--pack expects a directory");
|
|
482
|
+
} else if (a === "--json") {
|
|
483
|
+
json = true;
|
|
484
|
+
} else if (a === "--allow-unrendered-freeform") {
|
|
485
|
+
allowUnrenderedFreeform = true;
|
|
486
|
+
} else if (a.startsWith("-")) {
|
|
487
|
+
fail(`unknown flag: ${a}`);
|
|
488
|
+
} else {
|
|
489
|
+
fail(`unexpected argument: ${a}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (!packDir) fail("inspect-template-deck requires --pack <dir>");
|
|
493
|
+
|
|
494
|
+
const deckDir = resolveDeckArg(deck);
|
|
495
|
+
const resolveMod = await import(
|
|
496
|
+
pathToFileURL(path.join(SRC_DIR, "builders", "resolve-skill.mjs")).href
|
|
497
|
+
);
|
|
498
|
+
const skill = resolveMod.resolveSkillForDeck(deckDir, {
|
|
499
|
+
packDir: path.resolve(packDir),
|
|
500
|
+
});
|
|
501
|
+
if (skill.mode !== "fill-template") {
|
|
502
|
+
fail(`pack ${skill.skillDir} is mode ${skill.mode}, not fill-template`);
|
|
503
|
+
}
|
|
504
|
+
const engine = await import(
|
|
505
|
+
pathToFileURL(path.join(SRC_DIR, "pptx", "fill", "engine.mjs")).href
|
|
506
|
+
);
|
|
507
|
+
const result = engine.inspectFillTemplateDeck({
|
|
508
|
+
deckDir,
|
|
509
|
+
skill,
|
|
510
|
+
allowUnrenderedFreeform,
|
|
511
|
+
});
|
|
512
|
+
const payload = {
|
|
513
|
+
version: 1,
|
|
514
|
+
pack: {
|
|
515
|
+
name: skill.skillName,
|
|
516
|
+
mode: skill.mode,
|
|
517
|
+
directory: skill.skillDir,
|
|
518
|
+
template: result.templateKey,
|
|
519
|
+
locale: result.locale,
|
|
520
|
+
},
|
|
521
|
+
slides: result.sequence,
|
|
522
|
+
warnings: result.warnings,
|
|
523
|
+
problems: result.problems,
|
|
524
|
+
};
|
|
525
|
+
if (json) {
|
|
526
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
527
|
+
} else {
|
|
528
|
+
for (const item of result.sequence) {
|
|
529
|
+
const suffix = item.kind === "master" ? ` → template ${item.no}` : "";
|
|
530
|
+
process.stdout.write(`${item.file}: ${item.kind}${suffix}\n`);
|
|
531
|
+
}
|
|
532
|
+
for (const warning of result.warnings) process.stderr.write(` warning: ${warning}\n`);
|
|
533
|
+
}
|
|
534
|
+
if (result.problems.length) {
|
|
535
|
+
fail(`inspection failed:\n - ${result.problems.join("\n - ")}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
432
539
|
async function cmdBuildPptx(rest) {
|
|
433
540
|
const { deck, passthrough } = splitDeckArg(rest, "build-pptx");
|
|
434
541
|
let to = null;
|
|
435
542
|
let outPath = null;
|
|
543
|
+
let packDir = null;
|
|
544
|
+
let force = false;
|
|
436
545
|
for (let i = 0; i < passthrough.length; i++) {
|
|
437
546
|
const a = passthrough[i];
|
|
438
|
-
if (a === "--
|
|
547
|
+
if (a === "--force") {
|
|
548
|
+
force = true;
|
|
549
|
+
} else if (a === "--to") {
|
|
439
550
|
to = passthrough[++i];
|
|
440
551
|
if (!to) fail("--to expects a presId");
|
|
552
|
+
} else if (a === "--pack") {
|
|
553
|
+
packDir = passthrough[++i];
|
|
554
|
+
if (!packDir) fail("--pack expects a directory");
|
|
441
555
|
} else if (a === "--out" || a === "-o") {
|
|
442
556
|
outPath = passthrough[++i];
|
|
443
557
|
if (!outPath) fail("--out expects a path");
|
|
@@ -460,8 +574,13 @@ async function cmdBuildPptx(rest) {
|
|
|
460
574
|
const resolveMod = await import(
|
|
461
575
|
pathToFileURL(path.join(SRC_DIR, "builders", "resolve-skill.mjs")).href
|
|
462
576
|
);
|
|
463
|
-
|
|
464
|
-
|
|
577
|
+
const resolveOpts = packDir ? { packDir: path.resolve(packDir) } : {};
|
|
578
|
+
const deckMode = resolveMod.resolveDeckMode(deckDir, resolveOpts);
|
|
579
|
+
if (packDir && deckMode.mode !== "fill-template") {
|
|
580
|
+
fail(`explicit --pack ${path.resolve(packDir)} is mode ${deckMode.mode}, not fill-template`);
|
|
581
|
+
}
|
|
582
|
+
if (deckMode.mode === "fill-template") {
|
|
583
|
+
const skill = resolveMod.resolveSkillForDeck(deckDir, resolveOpts);
|
|
465
584
|
if (to) {
|
|
466
585
|
fail(
|
|
467
586
|
"--to targets a PowerPoint window, but fill-template decks build " +
|
|
@@ -519,6 +638,7 @@ async function cmdBuildPptx(rest) {
|
|
|
519
638
|
deckDir,
|
|
520
639
|
presentationId: to,
|
|
521
640
|
outPath,
|
|
641
|
+
force,
|
|
522
642
|
onProgress: renderProgressPrinter(),
|
|
523
643
|
});
|
|
524
644
|
process.stdout.write(
|
|
@@ -699,9 +819,211 @@ function writeSearchGrid(gridDataUri, searchId, resolveExt) {
|
|
|
699
819
|
}
|
|
700
820
|
}
|
|
701
821
|
|
|
822
|
+
// Where an engine op runs. Newer add-ins serve the engine surface off the CLI
|
|
823
|
+
// session itself (`/api/cli/engines`, `/api/cli/searches/*`), so discovery,
|
|
824
|
+
// search, download and extraction need no PowerPoint window at all; older ones
|
|
825
|
+
// relay every one of them through a task-pane webview, which is what
|
|
826
|
+
// requirePaneHost gates. The DIRECT path is preferred whenever it exists and the
|
|
827
|
+
// pane path stays as the compat fallback, so both add-in generations work.
|
|
828
|
+
//
|
|
829
|
+
// `--to <presId>` stops being a REQUIREMENT — the searchId is server-owned
|
|
830
|
+
// rather than bound to a window, so nothing has to be targeted — but it is still
|
|
831
|
+
// honoured, and naming a window means "relay through THAT pane". That is the
|
|
832
|
+
// escape hatch for the two things the pane-free routes refuse (`asset_needs_host`
|
|
833
|
+
// for SharePoint-hosted bytes, `recolor_needs_host` for an icon tint), both of
|
|
834
|
+
// which the pane can do; ignoring --to made their own remediation unreachable.
|
|
835
|
+
// `brandId` is the deck's bound design — the only thing that routes an engine op
|
|
836
|
+
// in a multi-environment session when no window is involved.
|
|
837
|
+
// No connected environment holds a server-owned search record. Authored once
|
|
838
|
+
// because every consumer of an opaque searchId reaches the same dead end and
|
|
839
|
+
// the same way out — the record's own environment is the only thing that can
|
|
840
|
+
// serve it, so a re-search is the fix regardless of who asked.
|
|
841
|
+
function expiredSearchMessage(searchId) {
|
|
842
|
+
return (
|
|
843
|
+
`search ${searchId} was not found on any connected environment — server-side ` +
|
|
844
|
+
"search records expire after ~2 hours. Re-run 'slidemind search' and pick again."
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function resolveEngineRouting(client, { to = null, brandId = null, pick = "one", searchId = null } = {}) {
|
|
849
|
+
// An opaque (server-minted) searchId pins the route by itself: the record
|
|
850
|
+
// lives on the environment that minted it, and the pane relay can never
|
|
851
|
+
// serve it — its handler only knows its own per-window numeric ids. So the
|
|
852
|
+
// record's origin outranks the deck's design AND --to, and when no connected
|
|
853
|
+
// environment holds the record the only honest answer is "search again",
|
|
854
|
+
// not a pane fallback that is guaranteed to 4xx.
|
|
855
|
+
if (client.isOpaqueSearchId?.(searchId)) {
|
|
856
|
+
if (to) {
|
|
857
|
+
fail(
|
|
858
|
+
`searchId ${searchId} is a pane-free search — it cannot be relayed through a window. ` +
|
|
859
|
+
`To go through the pane, re-run 'slidemind search "<query>" --to ${to}' and use ITS searchId.`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
let origin = null;
|
|
863
|
+
try {
|
|
864
|
+
origin = await client.resolveSearchBackend(searchId);
|
|
865
|
+
} catch (err) {
|
|
866
|
+
// The probe distinguishes "no environment holds this record" from the
|
|
867
|
+
// failures that only LOOK like it — a dead session, a missing sign-in —
|
|
868
|
+
// and hands the latter back for their own remediation.
|
|
869
|
+
await failOnEngineBlocker(client, err, { to });
|
|
870
|
+
}
|
|
871
|
+
if (origin) {
|
|
872
|
+
return { direct: true, backendURL: origin, presId: null, registry: null, entry: null };
|
|
873
|
+
}
|
|
874
|
+
fail(expiredSearchMessage(searchId));
|
|
875
|
+
}
|
|
876
|
+
let backendURL = null;
|
|
877
|
+
if (!to) {
|
|
878
|
+
backendURL = await client.resolveEngineBackend({ brandId });
|
|
879
|
+
}
|
|
880
|
+
if (backendURL) {
|
|
881
|
+
return { direct: true, backendURL, presId: null, registry: null, entry: null };
|
|
882
|
+
}
|
|
883
|
+
const registry = await client.requirePaneHost();
|
|
884
|
+
const entry =
|
|
885
|
+
pick === "any"
|
|
886
|
+
? client.pickAnyPresentationEntry(registry, to)
|
|
887
|
+
: client.pickPresentationEntry(registry, to);
|
|
888
|
+
return {
|
|
889
|
+
direct: false,
|
|
890
|
+
backendURL: entry.backendURL ?? null,
|
|
891
|
+
presId: entry.presId,
|
|
892
|
+
registry,
|
|
893
|
+
entry,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// The design a deck is bound to (presFP == brandId), or null when it isn't bound
|
|
898
|
+
// / isn't a deck. Routing state only — never printed.
|
|
899
|
+
async function deckBrandId(deckDir) {
|
|
900
|
+
if (!deckDir) return null;
|
|
901
|
+
const bundleMod = await import(
|
|
902
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
|
|
903
|
+
);
|
|
904
|
+
return bundleMod.readDeckBundleRef(deckDir)?.presFP ?? null;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// A pane-ready window, or null — never a throw, and never a wait. Used by the
|
|
908
|
+
// login_required paths below, where an open pane is an OPTION rather than a
|
|
909
|
+
// precondition. `to` is honoured only when it names a window that is actually
|
|
910
|
+
// registered; a stale --to must not cost the caller its fallback.
|
|
911
|
+
async function openPaneEntry(client, to = null) {
|
|
912
|
+
const registry = (await client.paneReadyPresentations?.()) ?? [];
|
|
913
|
+
if (registry.length === 0) return null;
|
|
914
|
+
const explicit = registry.some((p) => p.presId === to) ? to : null;
|
|
915
|
+
try {
|
|
916
|
+
return { registry, entry: client.pickAnyPresentationEntry(registry, explicit) };
|
|
917
|
+
} catch {
|
|
918
|
+
// Several windows and no way to choose — no automatic fallback from here.
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// The ONE rendering of a corpus-identity refusal: the pane-free routes run as
|
|
924
|
+
// the USER and answer `login_required` without the delegated tokens `slidemind
|
|
925
|
+
// login` mints (the CLI attempts that sign-in itself before ever sending a
|
|
926
|
+
// token-less request, so reaching this means the browser step was refused or
|
|
927
|
+
// abandoned). The refusal backs the PANE-FREE path only: an open task pane
|
|
928
|
+
// signs every request with a live browser token, so when a pane-ready window
|
|
929
|
+
// is already open, that window can serve a fresh search right now — say so.
|
|
930
|
+
// Returns the message, or null for any other failure.
|
|
931
|
+
async function loginRequiredMessage(client, err, { to = null } = {}) {
|
|
932
|
+
const blocker = client.loginRequiredBlocker?.(err);
|
|
933
|
+
if (!blocker) return null;
|
|
934
|
+
const pane = await openPaneEntry(client, to);
|
|
935
|
+
if (!pane) return blocker.message;
|
|
936
|
+
const env = envTag(pane.entry.env);
|
|
937
|
+
return (
|
|
938
|
+
`${blocker.message} A slidemind pane is already open${env} and signs each request live, so a fresh ` +
|
|
939
|
+
`search through that window works now: slidemind search "<query>" --to ${pane.entry.presId}.`
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Engine-route failures the USER has to clear get one actionable line instead of
|
|
944
|
+
// a relay error. `login_required` means the corpus routes want the user's own
|
|
945
|
+
// Microsoft 365 sign-in. Only corpus search stops; render and bundle don't.
|
|
946
|
+
// Anything else is re-thrown for the top-level handler.
|
|
947
|
+
async function failOnEngineBlocker(client, err, { to = null } = {}) {
|
|
948
|
+
const message = await loginRequiredMessage(client, err, { to });
|
|
949
|
+
if (message) fail(message);
|
|
950
|
+
throw err;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// A login_required refusal that an OPEN PANE can simply route around. Only an
|
|
954
|
+
// operation that can be replayed pane-side qualifies — creating a NEW search,
|
|
955
|
+
// whose id the pane mints itself. A record-bound op on an opaque searchId
|
|
956
|
+
// cannot: the pane's handler only understands its own per-window numeric ids,
|
|
957
|
+
// so those get loginRequiredMessage's extended blocker instead. Returns
|
|
958
|
+
// { registry, entry } or null.
|
|
959
|
+
async function paneRelayForLoginRequired(client, err, { to = null } = {}) {
|
|
960
|
+
if (!client.loginRequiredBlocker?.(err)) return null;
|
|
961
|
+
return openPaneEntry(client, to);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// The pane-free routes refuse exactly two things outright: bytes that live in
|
|
965
|
+
// SharePoint (`asset_needs_host`, which needs a delegated token only a browser
|
|
966
|
+
// can mint) and an icon tint (`recolor_needs_host`, whose classifier is
|
|
967
|
+
// DOM-based). An open pane does both, so the remediation is the RELAY — name it,
|
|
968
|
+
// rather than echoing a 409 whose "retry" would take the identical route.
|
|
969
|
+
// Returns null for anything else.
|
|
970
|
+
function describeHostOnlyEngineFailure(err) {
|
|
971
|
+
const code = err?.code ?? err?.body?.code ?? null;
|
|
972
|
+
if (code !== "asset_needs_host" && code !== "recolor_needs_host") return null;
|
|
973
|
+
const detail =
|
|
974
|
+
typeof err?.body?.error === "string" ? err.body.error : (err?.message ?? "");
|
|
975
|
+
return (
|
|
976
|
+
`${detail} Run 'slidemind search "<query>" --to <presId>' against an open window and ` +
|
|
977
|
+
"re-pick, then repeat this download with the same --to — that relays it through the pane."
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// The other half of the same boundary: a SharePoint-backed SLIDE. The extract
|
|
982
|
+
// route fetches it with the caller's identity, so without a delegated token the
|
|
983
|
+
// add-in falls back to a managed identity that is not configured for the corpus
|
|
984
|
+
// site and the fetch dies inside the provider. That route answers a plain 400
|
|
985
|
+
// with no discrete code, so the provider text is the only signal there is.
|
|
986
|
+
function isSharePointProviderFailure(err) {
|
|
987
|
+
const text = `${err?.body?.error ?? ""} ${err?.message ?? ""}`;
|
|
988
|
+
return /sharepoint provider/i.test(text);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// `slidemind login` as the WINDOW-FREE way past both — named only on a backend
|
|
992
|
+
// that honours the delegated token and only while no sign-in is stored, so the
|
|
993
|
+
// sentence never appears where following it would change nothing. Null
|
|
994
|
+
// otherwise, which keeps every pre-existing message byte-identical.
|
|
995
|
+
async function graphLoginRemediation(client, backendURL) {
|
|
996
|
+
if (!backendURL) return null;
|
|
997
|
+
try {
|
|
998
|
+
if (!(await client.backendSupportsGraphDelegated?.(backendURL))) return null;
|
|
999
|
+
const graph = await loadBridgeModule("graph-auth");
|
|
1000
|
+
if (graph.hasGraphSession(backendURL)) return null;
|
|
1001
|
+
} catch {
|
|
1002
|
+
// A capability probe that fails is not a reason to withhold the download's
|
|
1003
|
+
// real error — just say nothing extra.
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
return (
|
|
1007
|
+
"Or run 'slidemind login' once: it signs this CLI in to Microsoft 365 with your own " +
|
|
1008
|
+
"identity, and SharePoint-backed content then downloads with no window at all."
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// The user-facing text for an engine byte fetch that failed for want of an
|
|
1013
|
+
// identity — either refusal shape. Returns null when the failure is something
|
|
1014
|
+
// else entirely (the caller keeps its own message).
|
|
1015
|
+
async function describeEngineByteFailure(client, err, { backendURL = null } = {}) {
|
|
1016
|
+
const paneAdvice = describeHostOnlyEngineFailure(err);
|
|
1017
|
+
if (!paneAdvice && !isSharePointProviderFailure(err)) return null;
|
|
1018
|
+
const login = await graphLoginRemediation(client, backendURL);
|
|
1019
|
+
if (!login) return paneAdvice;
|
|
1020
|
+
return paneAdvice ? `${paneAdvice} ${login}` : `${err.message} ${login}`;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
702
1023
|
async function cmdSearch(rest) {
|
|
703
1024
|
let asJson = false;
|
|
704
1025
|
let to = null;
|
|
1026
|
+
let deck = null;
|
|
705
1027
|
let engineId = null;
|
|
706
1028
|
let resultType = null;
|
|
707
1029
|
let intent = null;
|
|
@@ -714,6 +1036,9 @@ async function cmdSearch(rest) {
|
|
|
714
1036
|
} else if (a === "--to") {
|
|
715
1037
|
to = rest[++i];
|
|
716
1038
|
if (!to) fail("--to expects a presId");
|
|
1039
|
+
} else if (a === "--deck") {
|
|
1040
|
+
deck = rest[++i];
|
|
1041
|
+
if (!deck) fail("--deck expects a deck name or path");
|
|
717
1042
|
} else if (a === "--engine" || a === "-e" || a === "--engineId") {
|
|
718
1043
|
engineId = rest[++i];
|
|
719
1044
|
if (!engineId) fail("--engine expects an engineId");
|
|
@@ -745,12 +1070,13 @@ async function cmdSearch(rest) {
|
|
|
745
1070
|
const query = queryParts.join(" ").trim();
|
|
746
1071
|
if (!query) {
|
|
747
1072
|
fail(
|
|
748
|
-
`usage: slidemind search "<query>" [--engine <id|scope:all|scope:sharepoint>] [--result-type slide|image] [--intent auto|content|visual] [--top <N>] [--to <presId>] [--json]`,
|
|
1073
|
+
`usage: slidemind search "<query>" [--engine <id|scope:all|scope:sharepoint>] [--result-type slide|image] [--intent auto|content|visual] [--top <N>] [--deck <deck>] [--to <presId>] [--json]`,
|
|
749
1074
|
);
|
|
750
1075
|
}
|
|
751
1076
|
|
|
752
|
-
// Engine search runs against the add-in's live engine over the bridge —
|
|
753
|
-
//
|
|
1077
|
+
// Engine search runs against the add-in's live engine over the bridge —
|
|
1078
|
+
// pane-free where the add-in serves the direct routes, relayed through an open
|
|
1079
|
+
// window (host required) where it doesn't.
|
|
754
1080
|
const client = await import(
|
|
755
1081
|
pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
|
|
756
1082
|
);
|
|
@@ -760,14 +1086,23 @@ async function cmdSearch(rest) {
|
|
|
760
1086
|
const { resolveAssetExt } = await import(
|
|
761
1087
|
pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
|
|
762
1088
|
);
|
|
763
|
-
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
1089
|
+
// The engine catalog is per-user, not window-bound. On the pane path search
|
|
1090
|
+
// attaches to any connected window (preferring a live one) and the chosen
|
|
1091
|
+
// window is printed below, because the searchId lives on that pane; on the
|
|
1092
|
+
// direct path the searchId is the backend's and no window is involved.
|
|
1093
|
+
const routing = await resolveEngineRouting(client, {
|
|
1094
|
+
to,
|
|
1095
|
+
brandId: await deckBrandId(deck ? resolveDeckArg(deck) : null),
|
|
1096
|
+
pick: "any",
|
|
1097
|
+
});
|
|
1098
|
+
const presId = routing.presId;
|
|
1099
|
+
const backendURL = routing.backendURL;
|
|
1100
|
+
// Mutable, because a login_required refusal can push a direct search onto
|
|
1101
|
+
// the pane relay below — and once it does, the searchId belongs to a WINDOW
|
|
1102
|
+
// and every next-step hint has to name it.
|
|
1103
|
+
let direct = routing.direct;
|
|
1104
|
+
let registry = routing.registry;
|
|
1105
|
+
let entry = routing.entry;
|
|
771
1106
|
|
|
772
1107
|
// An explicit content/visual intent means "search slides", not imagery.
|
|
773
1108
|
const slideIntent = intent === "content" || intent === "visual";
|
|
@@ -776,7 +1111,7 @@ async function cmdSearch(rest) {
|
|
|
776
1111
|
// - an explicit slide intent (--intent content|visual) targets slides.
|
|
777
1112
|
// This is belt-and-suspenders for an add-in regression where a missing
|
|
778
1113
|
// subtype no longer defaulted to "image"; when the subtype is unknown we omit
|
|
779
|
-
// it and let the add-in resolve
|
|
1114
|
+
// it and let the add-in resolve the caller's groups itself.
|
|
780
1115
|
let subtype = slideIntent ? "slide" : null;
|
|
781
1116
|
if (!engineId) {
|
|
782
1117
|
if (slideIntent) {
|
|
@@ -800,9 +1135,18 @@ async function cmdSearch(rest) {
|
|
|
800
1135
|
// Best-effort: a bundle that can't be materialized (e.g. an incomplete
|
|
801
1136
|
// one) must not replace the actionable "wire an image engine" message
|
|
802
1137
|
// below with a bundle error — the live catalog is the real answer here.
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1138
|
+
// Pane path: fetch by the window's presId. Direct path: there is no
|
|
1139
|
+
// window, so only a bound deck (--deck) can name the design; without one
|
|
1140
|
+
// there is nothing to fall back to and the live catalog's answer stands.
|
|
1141
|
+
const brandId = presId
|
|
1142
|
+
? null
|
|
1143
|
+
: await deckBrandId(deck ? resolveDeckArg(deck) : null);
|
|
1144
|
+
const mat =
|
|
1145
|
+
presId || brandId
|
|
1146
|
+
? await bundle
|
|
1147
|
+
.materializeBundle(presId, { backendURL, brandId })
|
|
1148
|
+
.catch(() => null)
|
|
1149
|
+
: null;
|
|
806
1150
|
if (mat) pick = bundle.pickImageEngine(bundle.readEnginesFromDir(mat.dir));
|
|
807
1151
|
}
|
|
808
1152
|
if (!pick) {
|
|
@@ -829,23 +1173,64 @@ async function cmdSearch(rest) {
|
|
|
829
1173
|
resultType = null;
|
|
830
1174
|
}
|
|
831
1175
|
|
|
832
|
-
//
|
|
833
|
-
// window
|
|
834
|
-
//
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1176
|
+
// Direct: one pane-free call against the backend that owns the searchId.
|
|
1177
|
+
// Pane: window-agnostic — tries the picked pane, then fails over to any other
|
|
1178
|
+
// live window on the same backend if it's suspended/frozen. The answering
|
|
1179
|
+
// window (res.presId) is what reuse must target there, since the searchId is
|
|
1180
|
+
// bound to it.
|
|
1181
|
+
const relaySearch = (reg) =>
|
|
1182
|
+
client.engineSearchAnyPane({
|
|
1183
|
+
registry: reg,
|
|
1184
|
+
explicitPresId: to,
|
|
1185
|
+
engineId,
|
|
1186
|
+
query,
|
|
1187
|
+
subtype,
|
|
1188
|
+
resultType,
|
|
1189
|
+
intent: intent ?? null,
|
|
1190
|
+
top: top ?? null,
|
|
1191
|
+
});
|
|
1192
|
+
let res;
|
|
1193
|
+
try {
|
|
1194
|
+
res = direct
|
|
1195
|
+
? await client.engineSearch({
|
|
1196
|
+
engineId,
|
|
1197
|
+
query,
|
|
1198
|
+
subtype,
|
|
1199
|
+
resultType,
|
|
1200
|
+
intent: intent ?? null,
|
|
1201
|
+
top: top ?? null,
|
|
1202
|
+
backendURL,
|
|
1203
|
+
})
|
|
1204
|
+
: await relaySearch(routing.registry);
|
|
1205
|
+
} catch (err) {
|
|
1206
|
+
// login_required stops the PANE-FREE path only — an open pane signs each
|
|
1207
|
+
// request with the browser's live token. Creating a new search is exactly
|
|
1208
|
+
// the operation the pane can replay (it mints its own searchId), so relay
|
|
1209
|
+
// it rather than parking the search on a sign-in the user hasn't finished.
|
|
1210
|
+
const relay = direct ? await paneRelayForLoginRequired(client, err, { to }) : null;
|
|
1211
|
+
if (!relay) await failOnEngineBlocker(client, err, { to });
|
|
1212
|
+
process.stderr.write(
|
|
1213
|
+
`note: this CLI is not signed in to Microsoft 365, so the search ran through the open ` +
|
|
1214
|
+
`slidemind pane${envTag(relay.entry.env)} instead (it signs each request live).\n`,
|
|
1215
|
+
);
|
|
1216
|
+
direct = false;
|
|
1217
|
+
registry = relay.registry;
|
|
1218
|
+
entry = relay.entry;
|
|
1219
|
+
try {
|
|
1220
|
+
res = await relaySearch(registry);
|
|
1221
|
+
} catch (relayErr) {
|
|
1222
|
+
await failOnEngineBlocker(client, relayErr, { to });
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
const ranPresId = res?.presId ?? entry?.presId ?? presId;
|
|
1226
|
+
const ranEntry = direct
|
|
1227
|
+
? { env: res?.env ?? null }
|
|
1228
|
+
: (registry.find((p) => p.presId === ranPresId) ?? entry);
|
|
847
1229
|
if (asJson) {
|
|
848
|
-
|
|
1230
|
+
// The searchId rides the JSON so an interrupted agent can resume its picks
|
|
1231
|
+
// without re-searching. The backendURL never does — env label only.
|
|
1232
|
+
const { backendURL: _routingOnly, ...pub } = res ?? {};
|
|
1233
|
+
process.stdout.write(`${JSON.stringify(pub, null, 2)}\n`);
|
|
849
1234
|
return;
|
|
850
1235
|
}
|
|
851
1236
|
if (!res?.ok) {
|
|
@@ -902,6 +1287,16 @@ async function cmdSearch(rest) {
|
|
|
902
1287
|
`Contact sheet of all ${count} result(s) — LOOK AT THIS FILE to choose, ` +
|
|
903
1288
|
`don't fetch previews one by one: ${gridPath}\n`,
|
|
904
1289
|
);
|
|
1290
|
+
} else if (direct) {
|
|
1291
|
+
// The pane composes the collage in a DOM; the pane-free route may not
|
|
1292
|
+
// have one to hand back (`gridUnavailable`, or simply no grid field on an
|
|
1293
|
+
// add-in that hasn't grown it yet). Say where previews come from instead
|
|
1294
|
+
// — otherwise an agent that has learned to expect a contact sheet quietly
|
|
1295
|
+
// issues N thumbnail fetches to rebuild it.
|
|
1296
|
+
process.stdout.write(
|
|
1297
|
+
`No contact sheet on this path — preview candidates one at a time instead: ` +
|
|
1298
|
+
`slidemind view-slide --search ${res.searchId} --pick <n>\n`,
|
|
1299
|
+
);
|
|
905
1300
|
}
|
|
906
1301
|
}
|
|
907
1302
|
// Tailor the next-step hint to the result kind: slide verbs (insert-slide /
|
|
@@ -910,12 +1305,28 @@ async function cmdSearch(rest) {
|
|
|
910
1305
|
// add-ins that don't echo a top-level subtype.
|
|
911
1306
|
const isSlideResults =
|
|
912
1307
|
res.subtype === "slide" || results.some((r) => r.kind === "slide");
|
|
913
|
-
const
|
|
914
|
-
if (
|
|
1308
|
+
const envLabel = envTag(ranEntry?.env);
|
|
1309
|
+
if (direct) {
|
|
1310
|
+
// The searchId belongs to the BACKEND here, not to a window: picks work with
|
|
1311
|
+
// PowerPoint closed, so the follow-up commands carry no --to. Live insert is
|
|
1312
|
+
// the one step that still names a window (it types into an open deck).
|
|
1313
|
+
if (isSlideResults) {
|
|
1314
|
+
process.stdout.write(
|
|
1315
|
+
`searchId ${res.searchId}${envLabel} is held by the add-in — reuse it with no PowerPoint window:\n` +
|
|
1316
|
+
` slidemind add-slide --search ${res.searchId} --pick <n> --deck <deck>\n` +
|
|
1317
|
+
` slidemind insert-slide --search ${res.searchId} --pick <n> --to <presId> (live insert needs an open window)\n`,
|
|
1318
|
+
);
|
|
1319
|
+
} else {
|
|
1320
|
+
process.stdout.write(
|
|
1321
|
+
`searchId ${res.searchId}${envLabel} is held by the add-in — download image result(s) into a deck (no window needed):\n` +
|
|
1322
|
+
` slidemind download-result --search ${res.searchId} --pick <n[,n,...]> --deck <deck> [--icon-color <hex>]\n`,
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
} else if (isSlideResults) {
|
|
915
1326
|
// The searchId is bound to the window the search ran on — reuse must target
|
|
916
1327
|
// that same window, so spell out its presId in the next-step commands.
|
|
917
1328
|
process.stdout.write(
|
|
918
|
-
`searchId ${res.searchId} ran on window ${ranPresId}${
|
|
1329
|
+
`searchId ${res.searchId} ran on window ${ranPresId}${envLabel} — reuse from THIS window:\n` +
|
|
919
1330
|
` slidemind insert-slide --search ${res.searchId} --pick <n> --to ${ranPresId}\n` +
|
|
920
1331
|
` slidemind add-slide --search ${res.searchId} --pick <n> --deck <deck> --to ${ranPresId}\n`,
|
|
921
1332
|
);
|
|
@@ -923,7 +1334,7 @@ async function cmdSearch(rest) {
|
|
|
923
1334
|
// The searchId is bound to the window the search ran on — download must
|
|
924
1335
|
// target that same window, so spell out its presId in the next-step command.
|
|
925
1336
|
process.stdout.write(
|
|
926
|
-
`searchId ${res.searchId} ran on window ${ranPresId}${
|
|
1337
|
+
`searchId ${res.searchId} ran on window ${ranPresId}${envLabel} — download image result(s) into a deck:\n` +
|
|
927
1338
|
` slidemind download-result --search ${res.searchId} --pick <n[,n,...]> --deck <deck> --to ${ranPresId} [--icon-color <hex>]\n`,
|
|
928
1339
|
);
|
|
929
1340
|
}
|
|
@@ -973,16 +1384,58 @@ async function cmdLs(rest) {
|
|
|
973
1384
|
if (all && to) fail("--all cannot be combined with --to");
|
|
974
1385
|
let backendURL = null;
|
|
975
1386
|
if (!all) {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1387
|
+
// Pane-free where the add-in serves the direct routes; otherwise the catalog
|
|
1388
|
+
// is relayed through a window, so a live pane is still the precondition.
|
|
1389
|
+
try {
|
|
1390
|
+
backendURL = (await resolveEngineRouting(client, { to, pick: "any" }))
|
|
1391
|
+
.backendURL;
|
|
1392
|
+
} catch (err) {
|
|
1393
|
+
// Listing is the one engine op that can simply show EVERY environment
|
|
1394
|
+
// (each engine is tagged with its env), so an ambiguous multi-environment
|
|
1395
|
+
// session fans out here rather than demanding --backend.
|
|
1396
|
+
if (err?.code !== "engine_backend_ambiguous") throw err;
|
|
1397
|
+
backendURL = null;
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
let engines;
|
|
1401
|
+
// The pane-free route's summary of the catalog BEFORE the type/subtype
|
|
1402
|
+
// filter; null on older add-ins and on the pane-relay path.
|
|
1403
|
+
let catalog = null;
|
|
1404
|
+
// Which environments answered. The catalog is POOLED, and pooling dedupes the
|
|
1405
|
+
// scopes every environment repeats — so a second environment can contribute
|
|
1406
|
+
// no row of its own, and the surviving rows can no longer be counted to learn
|
|
1407
|
+
// whether more than one answered.
|
|
1408
|
+
let envs = [];
|
|
1409
|
+
try {
|
|
1410
|
+
({ engines, catalog, envs = [] } = await client.listEngines({
|
|
1411
|
+
type,
|
|
1412
|
+
subtype,
|
|
1413
|
+
backendURL,
|
|
1414
|
+
}));
|
|
1415
|
+
} catch (err) {
|
|
1416
|
+
// No pane fallback here: `GET /api/cli/engines` is the catalog route on both
|
|
1417
|
+
// add-in generations (newer ones answer it directly, older ones relay it
|
|
1418
|
+
// themselves), so there is no second route to retry on. The message does
|
|
1419
|
+
// name the open pane, because searching THROUGH it still works.
|
|
1420
|
+
await failOnEngineBlocker(client, err, { to });
|
|
979
1421
|
}
|
|
980
|
-
const { engines } = await client.listEngines({ type, subtype, backendURL });
|
|
981
1422
|
if (asJson) {
|
|
982
|
-
process.stdout.write(`${JSON.stringify(engines, null, 2)}\n`);
|
|
1423
|
+
process.stdout.write(`${JSON.stringify({ engines, catalog, envs }, null, 2)}\n`);
|
|
983
1424
|
return;
|
|
984
1425
|
}
|
|
985
1426
|
if (engines.length === 0) {
|
|
1427
|
+
// A filter that matched nothing is NOT an empty deployment — the summary is
|
|
1428
|
+
// the only thing that tells the two apart, so say which one this is and the
|
|
1429
|
+
// next move follows (drop the filter vs. search the corpus unfiltered).
|
|
1430
|
+
const unfiltered = catalog ? catalog.totalEngines + catalog.totalScopes : 0;
|
|
1431
|
+
if (catalog?.filtered && unfiltered > 0) {
|
|
1432
|
+
process.stderr.write(
|
|
1433
|
+
`No engine or scope matched this filter, but ${catalog.totalEngines} engine(s) ` +
|
|
1434
|
+
`and ${catalog.totalScopes} scope(s) exist. Re-run 'slidemind ls engines' ` +
|
|
1435
|
+
"without --type/--subtype to see them.\n",
|
|
1436
|
+
);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
986
1439
|
// Deliberately not "nothing to search": an engine is a saved folder filter
|
|
987
1440
|
// over the add-in's shared corpus, so an empty catalog says only that nobody
|
|
988
1441
|
// saved a filter. An older add-in that predates scopes lands here too.
|
|
@@ -997,8 +1450,12 @@ async function cmdLs(rest) {
|
|
|
997
1450
|
);
|
|
998
1451
|
return;
|
|
999
1452
|
}
|
|
1000
|
-
// Show the env tag only when more than one environment contributed
|
|
1001
|
-
|
|
1453
|
+
// Show the env tag only when more than one environment contributed. Two
|
|
1454
|
+
// environments can carry identically-named engines, and without the tag the
|
|
1455
|
+
// pooled list gives no way to tell them apart. Deduped scope rows carry no env
|
|
1456
|
+
// of their own (they exist on every environment), so they stay untagged.
|
|
1457
|
+
const multiEnv =
|
|
1458
|
+
isMultiEnv() || envs.length > 1 || new Set(engines.map((e) => e.env)).size > 1;
|
|
1002
1459
|
for (const e of engines) {
|
|
1003
1460
|
const kind = [e.type, e.subtype].filter(Boolean).join("/");
|
|
1004
1461
|
const env = multiEnv && e.env ? `[${e.env}] ` : "";
|
|
@@ -1009,12 +1466,28 @@ async function cmdLs(rest) {
|
|
|
1009
1466
|
e.type === "scope"
|
|
1010
1467
|
? ` (needs --result-type ${(e.capabilities?.resultTypes ?? ["slide", "image"]).join("|")})`
|
|
1011
1468
|
: "";
|
|
1469
|
+
// An engine that exists but has nothing indexed yet returns zero hits for
|
|
1470
|
+
// every query; say so here so an empty search isn't read as a bad engine id.
|
|
1471
|
+
const state =
|
|
1472
|
+
e.content?.state === "empty"
|
|
1473
|
+
? " (nothing indexed yet)"
|
|
1474
|
+
: e.content?.state === "indexing"
|
|
1475
|
+
? " (indexing)"
|
|
1476
|
+
: "";
|
|
1012
1477
|
process.stdout.write(
|
|
1013
|
-
`${env}${e.id} · ${e.label ?? e.id} · ${kind}${hint}${desc}\n`,
|
|
1478
|
+
`${env}${e.id} · ${e.label ?? e.id} · ${kind}${hint}${state}${desc}\n`,
|
|
1014
1479
|
);
|
|
1015
1480
|
}
|
|
1016
1481
|
}
|
|
1017
1482
|
|
|
1483
|
+
// A searchId is a number when the pane minted it (the relay path) and an opaque
|
|
1484
|
+
// server-minted id on the pane-free engine routes, where the search record lives
|
|
1485
|
+
// on the backend. Accept both so a pick round-trips whichever search produced it.
|
|
1486
|
+
function parseSearchIdArg(raw, flag = "--search") {
|
|
1487
|
+
if (!raw || raw.startsWith("-")) fail(`${flag} expects a searchId, got '${raw}'`);
|
|
1488
|
+
return /^\d+$/.test(raw) ? parseInt(raw, 10) : raw;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1018
1491
|
function parsePicks(raw, flag = "--pick") {
|
|
1019
1492
|
if (!raw) fail(`${flag} expects a comma-separated list of selection numbers`);
|
|
1020
1493
|
// Tolerate a trailing/empty segment ("1," or "1,,2") by dropping blank parts
|
|
@@ -1034,6 +1507,45 @@ function parsePicks(raw, flag = "--pick") {
|
|
|
1034
1507
|
return picks;
|
|
1035
1508
|
}
|
|
1036
1509
|
|
|
1510
|
+
/**
|
|
1511
|
+
* Turn a live-insert failure into the action that actually fixes it. The client
|
|
1512
|
+
* has already re-listed the registry and retried ONCE against the live window
|
|
1513
|
+
* (a pane reconnect churns the presId), so a pane error that reaches here is one
|
|
1514
|
+
* only the user can clear — and the way around it is the window-free fold.
|
|
1515
|
+
*
|
|
1516
|
+
* A login_required refusal is NOT among them: it is authored once, in
|
|
1517
|
+
* loginRequiredMessage, which the caller consults first.
|
|
1518
|
+
*/
|
|
1519
|
+
function describeInsertSlideFailure(err, searchId) {
|
|
1520
|
+
const code = err?.code ?? err?.body?.code ?? null;
|
|
1521
|
+
if (code === "unknown_search") {
|
|
1522
|
+
return `searchId ${searchId} is no longer available. Run 'slidemind search' again, then re-pick.`;
|
|
1523
|
+
}
|
|
1524
|
+
if (code === "searchId_required") {
|
|
1525
|
+
// The pane was handed an id it cannot resolve — its search context is
|
|
1526
|
+
// per-window and numeric, and a server-minted id was never in it.
|
|
1527
|
+
return (
|
|
1528
|
+
`searchId ${searchId} belongs to the add-in, not to that PowerPoint window. ` +
|
|
1529
|
+
`Re-run 'slidemind search' (the CLI picks the reuse route from where the search ran), then re-pick.`
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
const paneAsleep =
|
|
1533
|
+
code === "pane_suspended" ||
|
|
1534
|
+
code === "pane_timeout" ||
|
|
1535
|
+
// A relay 404 about the presId itself carries prose and no machine code,
|
|
1536
|
+
// and bridgeFetch falls `code` back to that prose — so read the PARSED one.
|
|
1537
|
+
(err?.status === 404 &&
|
|
1538
|
+
(!err?.body?.code || err.body.code === "unknown_presentation"));
|
|
1539
|
+
if (paneAsleep) {
|
|
1540
|
+
return (
|
|
1541
|
+
"the slidemind task pane in that PowerPoint window is not responding (already retried once). " +
|
|
1542
|
+
"Foreground PowerPoint and click the slidemind pane to wake it, then re-run — or reuse the same " +
|
|
1543
|
+
`result with no window at all: slidemind add-slide --search ${searchId} --pick <n> --deck <deck>.`
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
return `insert-slide failed: ${err?.message ?? "unknown error"}`;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1037
1549
|
async function cmdInsertSlide(rest) {
|
|
1038
1550
|
let searchId = null;
|
|
1039
1551
|
let picks = null;
|
|
@@ -1042,10 +1554,7 @@ async function cmdInsertSlide(rest) {
|
|
|
1042
1554
|
for (let i = 0; i < rest.length; i++) {
|
|
1043
1555
|
const a = rest[i];
|
|
1044
1556
|
if (a === "--search") {
|
|
1045
|
-
|
|
1046
|
-
const n = parseInt(next, 10);
|
|
1047
|
-
if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
|
|
1048
|
-
searchId = n;
|
|
1557
|
+
searchId = parseSearchIdArg(rest[++i]);
|
|
1049
1558
|
} else if (a === "--pick") {
|
|
1050
1559
|
picks = parsePicks(rest[++i]);
|
|
1051
1560
|
} else if (a === "--to") {
|
|
@@ -1070,20 +1579,97 @@ async function cmdInsertSlide(rest) {
|
|
|
1070
1579
|
const client = await import(
|
|
1071
1580
|
pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
|
|
1072
1581
|
);
|
|
1582
|
+
// Live insert types into an OPEN presentation, so it stays window- and
|
|
1583
|
+
// pane-bound even where the rest of the engine surface has moved off the pane.
|
|
1073
1584
|
const registry = await client.requirePaneHost();
|
|
1074
1585
|
const entry = client.pickPresentationEntry(registry, to);
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1586
|
+
// Window identity for the client's single retry: a pane reconnect mints a NEW
|
|
1587
|
+
// presId for the same window, and the design fingerprint re-matches it — but
|
|
1588
|
+
// only against windows we did NOT already see next to the target, or a second
|
|
1589
|
+
// deck on the same corporate design would take the insert.
|
|
1590
|
+
const identity = {
|
|
1591
|
+
fileName: entry.fileName ?? entry.title ?? null,
|
|
1592
|
+
brandId: entry.brandId ?? null,
|
|
1593
|
+
otherPresIds: registry
|
|
1594
|
+
.map((p) => p.presId)
|
|
1595
|
+
.filter((id) => id && id !== entry.presId),
|
|
1596
|
+
};
|
|
1597
|
+
// WHERE the searchId lives decides how the reuse runs. A number came from
|
|
1598
|
+
// that window's own pane search and the pane resolves it; an opaque id is the
|
|
1599
|
+
// backend's (the pane-free search) and the pane has never seen it — so those
|
|
1600
|
+
// picks are extracted pane-free and inserted as bytes instead.
|
|
1601
|
+
//
|
|
1602
|
+
// Which backend extracts them is the RECORD's, not the window's. Those are
|
|
1603
|
+
// different environments as soon as two are connected, and spending a
|
|
1604
|
+
// server-owned id against the window's origin 404s as `unknown_search` — read
|
|
1605
|
+
// as "the search expired", which sends the agent round a re-search loop that
|
|
1606
|
+
// can never resolve. The window stays the INSERT target either way.
|
|
1607
|
+
let engineBackendURL = null;
|
|
1608
|
+
if (client.isOpaqueSearchId?.(searchId)) {
|
|
1609
|
+
try {
|
|
1610
|
+
engineBackendURL = await client.resolveSearchBackend(searchId);
|
|
1611
|
+
} catch (err) {
|
|
1612
|
+
await failOnEngineBlocker(client, err, { to: entry.presId });
|
|
1613
|
+
}
|
|
1614
|
+
if (!engineBackendURL) fail(expiredSearchMessage(searchId));
|
|
1615
|
+
}
|
|
1616
|
+
let res;
|
|
1617
|
+
try {
|
|
1618
|
+
res = engineBackendURL
|
|
1619
|
+
? await client.insertSlidesFromSearch({
|
|
1620
|
+
presId: entry.presId,
|
|
1621
|
+
searchId,
|
|
1622
|
+
selections: picks,
|
|
1623
|
+
at,
|
|
1624
|
+
backendURL: entry.backendURL ?? null,
|
|
1625
|
+
engineBackendURL,
|
|
1626
|
+
...identity,
|
|
1627
|
+
})
|
|
1628
|
+
: await client.insertSlideLive({
|
|
1629
|
+
presId: entry.presId,
|
|
1630
|
+
searchId,
|
|
1631
|
+
selections: picks,
|
|
1632
|
+
at,
|
|
1633
|
+
backendURL: entry.backendURL ?? null,
|
|
1634
|
+
...identity,
|
|
1635
|
+
});
|
|
1636
|
+
} catch (err) {
|
|
1637
|
+
fail(
|
|
1638
|
+
(await loginRequiredMessage(client, err, { to: entry.presId })) ??
|
|
1639
|
+
describeInsertSlideFailure(err, searchId),
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1082
1642
|
if (!res?.ok) {
|
|
1083
1643
|
fail(`insert-slide failed: ${res?.error ?? "unknown error"}`);
|
|
1084
1644
|
}
|
|
1085
1645
|
process.stdout.write(
|
|
1086
|
-
`Inserted ${res.insertedCount ?? picks.length} slide(s) into presentation ${entry.presId}\n`,
|
|
1646
|
+
`Inserted ${res.insertedCount ?? picks.length} slide(s) into presentation ${res.presId ?? entry.presId}\n`,
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// One line per written slide, plus the machine-readable twin. The searchId is
|
|
1651
|
+
// echoed on purpose: an agent interrupted between picks can resume from the JSON
|
|
1652
|
+
// without re-running the search (the record lives on the backend now).
|
|
1653
|
+
function writeAddSlideResult({ asJson, searchId, deckDir, deck, written, slides }) {
|
|
1654
|
+
if (asJson) {
|
|
1655
|
+
process.stdout.write(
|
|
1656
|
+
`${JSON.stringify(
|
|
1657
|
+
{
|
|
1658
|
+
ok: true,
|
|
1659
|
+
searchId,
|
|
1660
|
+
deck: path.relative(process.cwd(), deckDir),
|
|
1661
|
+
slides,
|
|
1662
|
+
},
|
|
1663
|
+
null,
|
|
1664
|
+
2,
|
|
1665
|
+
)}\n`,
|
|
1666
|
+
);
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
process.stdout.write(
|
|
1670
|
+
`Added ${written.length} reused slide(s) to ${path.relative(process.cwd(), deckDir)}:\n` +
|
|
1671
|
+
written.map((w) => ` ${w}`).join("\n") +
|
|
1672
|
+
`\nThey are native .pptx in slides/ — re-run 'slidemind build-pptx ${deck}' or 'slidemind push ${deck}' to render the deck with them spliced in.\n`,
|
|
1087
1673
|
);
|
|
1088
1674
|
}
|
|
1089
1675
|
|
|
@@ -1094,13 +1680,13 @@ async function cmdAddSlide(rest) {
|
|
|
1094
1680
|
let at = null;
|
|
1095
1681
|
let to = null;
|
|
1096
1682
|
let from = null;
|
|
1683
|
+
let asJson = false;
|
|
1097
1684
|
for (let i = 0; i < rest.length; i++) {
|
|
1098
1685
|
const a = rest[i];
|
|
1099
|
-
if (a === "--
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
searchId = n;
|
|
1686
|
+
if (a === "--json") {
|
|
1687
|
+
asJson = true;
|
|
1688
|
+
} else if (a === "--search") {
|
|
1689
|
+
searchId = parseSearchIdArg(rest[++i]);
|
|
1104
1690
|
} else if (a === "--from") {
|
|
1105
1691
|
from = rest[++i];
|
|
1106
1692
|
if (!from) fail("--from expects a path to a .pptx file");
|
|
@@ -1173,21 +1759,30 @@ async function cmdAddSlide(rest) {
|
|
|
1173
1759
|
.filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"));
|
|
1174
1760
|
const targets = reused.reusedSlideTargets(existing, results, at);
|
|
1175
1761
|
const written = [];
|
|
1762
|
+
const slides = [];
|
|
1176
1763
|
results.forEach((r, i) => {
|
|
1177
1764
|
const target = path.join(slidesDir, targets[i].filename);
|
|
1178
1765
|
fs.writeFileSync(target, Buffer.from(r.bytes));
|
|
1179
1766
|
written.push(path.relative(process.cwd(), target));
|
|
1767
|
+
slides.push({
|
|
1768
|
+
selection: picks[i] ?? null,
|
|
1769
|
+
slideId: r.slideId ?? null,
|
|
1770
|
+
file: path.relative(process.cwd(), target),
|
|
1771
|
+
});
|
|
1772
|
+
});
|
|
1773
|
+
writeAddSlideResult({
|
|
1774
|
+
asJson,
|
|
1775
|
+
searchId: null,
|
|
1776
|
+
deckDir,
|
|
1777
|
+
deck,
|
|
1778
|
+
written,
|
|
1779
|
+
slides,
|
|
1180
1780
|
});
|
|
1181
|
-
process.stdout.write(
|
|
1182
|
-
`Added ${written.length} reused slide(s) to ${path.relative(process.cwd(), deckDir)}:\n` +
|
|
1183
|
-
written.map((w) => ` ${w}`).join("\n") +
|
|
1184
|
-
`\nThey are native .pptx in slides/ — re-run 'slidemind build-pptx ${deck}' or 'slidemind push ${deck}' to render the deck with them spliced in.\n`,
|
|
1185
|
-
);
|
|
1186
1781
|
return;
|
|
1187
1782
|
}
|
|
1188
1783
|
|
|
1189
1784
|
if (searchId === null || !picks || !deck) {
|
|
1190
|
-
fail("usage: slidemind add-slide (--search <id> | --from <file.pptx>) --pick <n[,n,...]> --deck <deck> [--to <presId>] [--at <N>]");
|
|
1785
|
+
fail("usage: slidemind add-slide (--search <id> | --from <file.pptx>) --pick <n[,n,...]> --deck <deck> [--to <presId>] [--at <N>] [--json]");
|
|
1191
1786
|
}
|
|
1192
1787
|
const deckDir = resolveDeckArg(deck);
|
|
1193
1788
|
const slidesDir = path.join(deckDir, "slides");
|
|
@@ -1196,12 +1791,17 @@ async function cmdAddSlide(rest) {
|
|
|
1196
1791
|
const client = await import(
|
|
1197
1792
|
pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
|
|
1198
1793
|
);
|
|
1199
|
-
|
|
1200
|
-
//
|
|
1201
|
-
//
|
|
1202
|
-
|
|
1203
|
-
const
|
|
1204
|
-
|
|
1794
|
+
// Direct path: an opaque searchId routes to the environment that minted the
|
|
1795
|
+
// record; the deck's bound design only decides when the id can't. Pane path:
|
|
1796
|
+
// the searchId is presentation-scoped, so --to targets the window the search
|
|
1797
|
+
// ran on (without it, a registry with >1 open window is a dead-end).
|
|
1798
|
+
const routing = await resolveEngineRouting(client, {
|
|
1799
|
+
to,
|
|
1800
|
+
brandId: await deckBrandId(deckDir),
|
|
1801
|
+
searchId,
|
|
1802
|
+
});
|
|
1803
|
+
const presId = routing.presId;
|
|
1804
|
+
const backendURL = routing.backendURL;
|
|
1205
1805
|
|
|
1206
1806
|
// Extract each picked slide's bytes over the bridge, then let the shared
|
|
1207
1807
|
// helper compute the play-order filenames (NN-reused-<shortid>.pptx). The
|
|
@@ -1222,9 +1822,12 @@ async function cmdAddSlide(rest) {
|
|
|
1222
1822
|
if (s.tempPath) fs.rmSync(s.tempPath, { force: true });
|
|
1223
1823
|
}
|
|
1224
1824
|
};
|
|
1825
|
+
// A searchId can be an opaque server id on the direct path — keep it usable as
|
|
1826
|
+
// a file name component.
|
|
1827
|
+
const searchKey = String(searchId).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1225
1828
|
try {
|
|
1226
1829
|
for (const pick of picks) {
|
|
1227
|
-
const tempPath = path.join(slidesDir, `.reused-${
|
|
1830
|
+
const tempPath = path.join(slidesDir, `.reused-${searchKey}-${pick}.part`);
|
|
1228
1831
|
if (typeof client.extractSlideToFile === "function") {
|
|
1229
1832
|
try {
|
|
1230
1833
|
const res = await client.extractSlideToFile({
|
|
@@ -1240,13 +1843,22 @@ async function cmdAddSlide(rest) {
|
|
|
1240
1843
|
continue;
|
|
1241
1844
|
} catch (err) {
|
|
1242
1845
|
fs.rmSync(tempPath, { force: true });
|
|
1846
|
+
const stale = await loginRequiredMessage(client, err, { to });
|
|
1847
|
+
if (stale) {
|
|
1848
|
+
cleanupTemps();
|
|
1849
|
+
fail(stale);
|
|
1850
|
+
}
|
|
1243
1851
|
// Route absent (bodyless 404 / 501) → this add-in predates the streaming
|
|
1244
|
-
// route; fall through to the capped base64 ack path below.
|
|
1852
|
+
// route; fall through to the capped base64 ack path below. On the
|
|
1853
|
+
// pane-free path there is no ack path to fall back to.
|
|
1245
1854
|
const routeMissing =
|
|
1246
1855
|
(err?.status === 404 && !err?.code) || err?.status === 501;
|
|
1247
|
-
if (!routeMissing) {
|
|
1856
|
+
if (!routeMissing || !presId) {
|
|
1857
|
+
const described = await describeEngineByteFailure(client, err, {
|
|
1858
|
+
backendURL,
|
|
1859
|
+
});
|
|
1248
1860
|
cleanupTemps();
|
|
1249
|
-
fail(`add-slide failed for selection ${pick}: ${err.message}`);
|
|
1861
|
+
fail(`add-slide failed for selection ${pick}: ${described ?? err.message}`);
|
|
1250
1862
|
}
|
|
1251
1863
|
}
|
|
1252
1864
|
}
|
|
@@ -1271,6 +1883,7 @@ async function cmdAddSlide(rest) {
|
|
|
1271
1883
|
.filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"));
|
|
1272
1884
|
const targets = reused.reusedSlideTargets(existing, extracted, at);
|
|
1273
1885
|
const written = [];
|
|
1886
|
+
const slides = [];
|
|
1274
1887
|
extracted.forEach((slide, i) => {
|
|
1275
1888
|
const target = path.join(slidesDir, targets[i].filename);
|
|
1276
1889
|
if (slide.tempPath) {
|
|
@@ -1279,12 +1892,13 @@ async function cmdAddSlide(rest) {
|
|
|
1279
1892
|
fs.writeFileSync(target, Buffer.from(slide.base64, "base64"));
|
|
1280
1893
|
}
|
|
1281
1894
|
written.push(path.relative(process.cwd(), target));
|
|
1895
|
+
slides.push({
|
|
1896
|
+
selection: slide.selection,
|
|
1897
|
+
slideId: slide.slideId ?? null,
|
|
1898
|
+
file: path.relative(process.cwd(), target),
|
|
1899
|
+
});
|
|
1282
1900
|
});
|
|
1283
|
-
|
|
1284
|
-
`Added ${written.length} reused slide(s) to ${path.relative(process.cwd(), deckDir)}:\n` +
|
|
1285
|
-
written.map((w) => ` ${w}`).join("\n") +
|
|
1286
|
-
`\nThey are native .pptx in slides/ — re-run 'slidemind build-pptx ${deck}' or 'slidemind push ${deck}' to render the deck with them spliced in.\n`,
|
|
1287
|
-
);
|
|
1901
|
+
writeAddSlideResult({ asJson, searchId, deckDir, deck, written, slides });
|
|
1288
1902
|
}
|
|
1289
1903
|
|
|
1290
1904
|
// Map a `--icon-color` value onto the `--pick` list. Accepts either one colour
|
|
@@ -1318,10 +1932,7 @@ async function cmdViewSlide(rest) {
|
|
|
1318
1932
|
for (let i = 0; i < rest.length; i++) {
|
|
1319
1933
|
const a = rest[i];
|
|
1320
1934
|
if (a === "--search") {
|
|
1321
|
-
|
|
1322
|
-
const n = parseInt(next, 10);
|
|
1323
|
-
if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
|
|
1324
|
-
searchId = n;
|
|
1935
|
+
searchId = parseSearchIdArg(rest[++i]);
|
|
1325
1936
|
} else if (a === "--pick") {
|
|
1326
1937
|
const next = rest[++i];
|
|
1327
1938
|
const n = parseInt(next, 10);
|
|
@@ -1342,7 +1953,7 @@ async function cmdViewSlide(rest) {
|
|
|
1342
1953
|
}
|
|
1343
1954
|
}
|
|
1344
1955
|
if (searchId === null || pick === null) {
|
|
1345
|
-
fail("usage: slidemind view-slide --search <id> --pick <n> --to <presId> [--out <dir>]");
|
|
1956
|
+
fail("usage: slidemind view-slide --search <id> --pick <n> [--to <presId>] [--out <dir>]");
|
|
1346
1957
|
}
|
|
1347
1958
|
const client = await import(
|
|
1348
1959
|
pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
|
|
@@ -1350,16 +1961,18 @@ async function cmdViewSlide(rest) {
|
|
|
1350
1961
|
const { resolveAssetExt } = await import(
|
|
1351
1962
|
pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
|
|
1352
1963
|
);
|
|
1353
|
-
const
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
})
|
|
1964
|
+
const routing = await resolveEngineRouting(client, { to, searchId });
|
|
1965
|
+
let res;
|
|
1966
|
+
try {
|
|
1967
|
+
res = await client.viewSlide({
|
|
1968
|
+
presId: routing.presId,
|
|
1969
|
+
searchId,
|
|
1970
|
+
selection: pick,
|
|
1971
|
+
backendURL: routing.backendURL,
|
|
1972
|
+
});
|
|
1973
|
+
} catch (err) {
|
|
1974
|
+
await failOnEngineBlocker(client, err, { to });
|
|
1975
|
+
}
|
|
1363
1976
|
if (!res?.ok || !res.dataUri) {
|
|
1364
1977
|
fail(`view-slide failed: ${res?.error ?? "no image returned"}`);
|
|
1365
1978
|
}
|
|
@@ -1383,6 +1996,135 @@ async function cmdViewSlide(rest) {
|
|
|
1383
1996
|
);
|
|
1384
1997
|
}
|
|
1385
1998
|
|
|
1999
|
+
async function cmdReadSource(rest) {
|
|
2000
|
+
let searchId = null;
|
|
2001
|
+
let pick = null;
|
|
2002
|
+
let fromSlide = null;
|
|
2003
|
+
let toSlide = null;
|
|
2004
|
+
let to = null;
|
|
2005
|
+
let outFile = null;
|
|
2006
|
+
for (let i = 0; i < rest.length; i++) {
|
|
2007
|
+
const a = rest[i];
|
|
2008
|
+
if (a === "--search") {
|
|
2009
|
+
searchId = parseSearchIdArg(rest[++i]);
|
|
2010
|
+
} else if (a === "--pick") {
|
|
2011
|
+
const next = rest[++i];
|
|
2012
|
+
const n = parseInt(next, 10);
|
|
2013
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
2014
|
+
fail(`--pick expects a positive selection number, got '${next}'`);
|
|
2015
|
+
}
|
|
2016
|
+
pick = n;
|
|
2017
|
+
} else if (a === "--from-slide") {
|
|
2018
|
+
const next = rest[++i];
|
|
2019
|
+
const n = parseInt(next, 10);
|
|
2020
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
2021
|
+
fail(`--from-slide expects a positive slide number, got '${next}'`);
|
|
2022
|
+
}
|
|
2023
|
+
fromSlide = n;
|
|
2024
|
+
} else if (a === "--to-slide") {
|
|
2025
|
+
const next = rest[++i];
|
|
2026
|
+
const n = parseInt(next, 10);
|
|
2027
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
2028
|
+
fail(`--to-slide expects a positive slide number, got '${next}'`);
|
|
2029
|
+
}
|
|
2030
|
+
toSlide = n;
|
|
2031
|
+
} else if (a === "--to") {
|
|
2032
|
+
// Deliberately the WINDOW, matching every other search-scoped command.
|
|
2033
|
+
// The slide window is --from-slide/--to-slide.
|
|
2034
|
+
to = rest[++i];
|
|
2035
|
+
if (!to) fail("--to expects a presId");
|
|
2036
|
+
} else if (a === "--out" || a === "-o") {
|
|
2037
|
+
outFile = rest[++i];
|
|
2038
|
+
if (!outFile) fail("--out expects a file path");
|
|
2039
|
+
} else if (a.startsWith("-")) {
|
|
2040
|
+
fail(`unknown flag: ${a}`);
|
|
2041
|
+
} else {
|
|
2042
|
+
fail(`unexpected argument: ${a}`);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
if (searchId === null || pick === null) {
|
|
2046
|
+
fail(
|
|
2047
|
+
"usage: slidemind read-source --search <id> --pick <n> [--from-slide <n>] [--to-slide <n>] [--to <presId>] [--out <file>]",
|
|
2048
|
+
);
|
|
2049
|
+
}
|
|
2050
|
+
const client = await import(
|
|
2051
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
|
|
2052
|
+
);
|
|
2053
|
+
// Pane path: the searchId is presentation-scoped, so --to targets the window
|
|
2054
|
+
// the search ran on — same rule as view-slide. Direct path: the record is the
|
|
2055
|
+
// backend's, so no window is involved.
|
|
2056
|
+
const routing = await resolveEngineRouting(client, { to, searchId });
|
|
2057
|
+
let res;
|
|
2058
|
+
try {
|
|
2059
|
+
res = await client.readSourcePresentation({
|
|
2060
|
+
presId: routing.presId,
|
|
2061
|
+
searchId,
|
|
2062
|
+
selection: pick,
|
|
2063
|
+
fromSlide,
|
|
2064
|
+
toSlide,
|
|
2065
|
+
backendURL: routing.backendURL,
|
|
2066
|
+
});
|
|
2067
|
+
} catch (err) {
|
|
2068
|
+
// The login_required line is authored once and knows about an open pane;
|
|
2069
|
+
// everything else is this command's own vocabulary.
|
|
2070
|
+
fail(
|
|
2071
|
+
(await loginRequiredMessage(client, err, { to })) ??
|
|
2072
|
+
describeReadSourceFailure(err, searchId, pick),
|
|
2073
|
+
);
|
|
2074
|
+
}
|
|
2075
|
+
if (!res?.markdown) {
|
|
2076
|
+
fail("read-source returned no content. Try the search again, then re-pick.");
|
|
2077
|
+
}
|
|
2078
|
+
if (outFile) {
|
|
2079
|
+
const target = path.resolve(process.cwd(), outFile);
|
|
2080
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
2081
|
+
fs.writeFileSync(target, res.markdown, "utf8");
|
|
2082
|
+
process.stdout.write(
|
|
2083
|
+
`Wrote the source presentation of result ${pick} (searchId ${searchId}) to ${target}\n`,
|
|
2084
|
+
);
|
|
2085
|
+
} else {
|
|
2086
|
+
process.stdout.write(res.markdown.endsWith("\n") ? res.markdown : `${res.markdown}\n`);
|
|
2087
|
+
}
|
|
2088
|
+
// Truncation is the one thing a reader must not miss: the body they just got
|
|
2089
|
+
// is a slide map, not the deck.
|
|
2090
|
+
if (res.truncated) {
|
|
2091
|
+
process.stderr.write(
|
|
2092
|
+
`note: this deck was too long to return in full — the output is its slide map. ` +
|
|
2093
|
+
`Re-run with --from-slide/--to-slide to read a range.\n`,
|
|
2094
|
+
);
|
|
2095
|
+
} else if (res.totalSlideCount && res.slideCount < res.totalSlideCount) {
|
|
2096
|
+
process.stderr.write(
|
|
2097
|
+
`note: showing ${res.slideCount} of ${res.totalSlideCount} slides.\n`,
|
|
2098
|
+
);
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
/**
|
|
2103
|
+
* Turn a source-presentation failure into the action that actually fixes it.
|
|
2104
|
+
* A login_required refusal is handled one level up, in loginRequiredMessage —
|
|
2105
|
+
* it is the one blocker only the USER can clear and it reads identically
|
|
2106
|
+
* across every command.
|
|
2107
|
+
*/
|
|
2108
|
+
function describeReadSourceFailure(err, searchId, pick) {
|
|
2109
|
+
const code = err?.code ?? err?.body?.code ?? null;
|
|
2110
|
+
switch (code) {
|
|
2111
|
+
case "unknown_search":
|
|
2112
|
+
return `searchId ${searchId} is no longer available on that window. Run 'slidemind search' again, then re-pick.`;
|
|
2113
|
+
case "wrong_result_type":
|
|
2114
|
+
return `result ${pick} has no source presentation — only slide search results come from a deck (image and collection results do not).`;
|
|
2115
|
+
case "access_denied":
|
|
2116
|
+
return "you do not have access to that result's source presentation.";
|
|
2117
|
+
case "no_indexed_content":
|
|
2118
|
+
return "that presentation has no indexed slides yet. It may still be syncing — try again later.";
|
|
2119
|
+
case "invalid_source":
|
|
2120
|
+
return `result ${pick} carries no source file reference. Re-run the search, or pick a different result.`;
|
|
2121
|
+
case "source_presentation_unsupported":
|
|
2122
|
+
return err.message;
|
|
2123
|
+
default:
|
|
2124
|
+
return `read-source failed: ${err?.message ?? "unknown error"}`;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
|
|
1386
2128
|
async function cmdDownloadResult(rest) {
|
|
1387
2129
|
let searchId = null;
|
|
1388
2130
|
let picks = null;
|
|
@@ -1392,10 +2134,7 @@ async function cmdDownloadResult(rest) {
|
|
|
1392
2134
|
for (let i = 0; i < rest.length; i++) {
|
|
1393
2135
|
const a = rest[i];
|
|
1394
2136
|
if (a === "--search") {
|
|
1395
|
-
|
|
1396
|
-
const n = parseInt(next, 10);
|
|
1397
|
-
if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
|
|
1398
|
-
searchId = n;
|
|
2137
|
+
searchId = parseSearchIdArg(rest[++i]);
|
|
1399
2138
|
} else if (a === "--pick") {
|
|
1400
2139
|
picks = parsePicks(rest[++i]);
|
|
1401
2140
|
} else if (a === "--deck") {
|
|
@@ -1415,7 +2154,7 @@ async function cmdDownloadResult(rest) {
|
|
|
1415
2154
|
}
|
|
1416
2155
|
if (searchId === null || !picks || !deck) {
|
|
1417
2156
|
fail(
|
|
1418
|
-
"usage: slidemind download-result --search <id> --pick <n[,n,...]> --deck <deck> --to <presId> [--icon-color <hex[,hex,...]>]",
|
|
2157
|
+
"usage: slidemind download-result --search <id> --pick <n[,n,...]> --deck <deck> [--to <presId>] [--icon-color <hex[,hex,...]>]",
|
|
1419
2158
|
);
|
|
1420
2159
|
}
|
|
1421
2160
|
const iconColors = parseIconColors(iconColorsRaw, picks.length);
|
|
@@ -1430,10 +2169,15 @@ async function cmdDownloadResult(rest) {
|
|
|
1430
2169
|
const { downloadOneResult } = await import(
|
|
1431
2170
|
pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
|
|
1432
2171
|
);
|
|
1433
|
-
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
|
|
2172
|
+
// Direct path: an opaque searchId routes to the record's own environment,
|
|
2173
|
+
// else the deck's bound design decides; no window needed. Pane path: the
|
|
2174
|
+
// searchId is presentation-scoped, so --to targets the window the search ran
|
|
2175
|
+
// on (without it, >1 open window is a dead-end).
|
|
2176
|
+
const routing = await resolveEngineRouting(client, {
|
|
2177
|
+
to,
|
|
2178
|
+
brandId: await deckBrandId(deckDir),
|
|
2179
|
+
searchId,
|
|
2180
|
+
});
|
|
1437
2181
|
|
|
1438
2182
|
// Concurrent — each download is its own bridge round-trip; allSettled so one
|
|
1439
2183
|
// bad selection doesn't sink the rest of the batch.
|
|
@@ -1441,8 +2185,8 @@ async function cmdDownloadResult(rest) {
|
|
|
1441
2185
|
picks.map((selection, i) =>
|
|
1442
2186
|
downloadOneResult({
|
|
1443
2187
|
client,
|
|
1444
|
-
presId:
|
|
1445
|
-
backendURL:
|
|
2188
|
+
presId: routing.presId,
|
|
2189
|
+
backendURL: routing.backendURL,
|
|
1446
2190
|
searchId,
|
|
1447
2191
|
assetsDir,
|
|
1448
2192
|
selection,
|
|
@@ -1450,10 +2194,26 @@ async function cmdDownloadResult(rest) {
|
|
|
1450
2194
|
}),
|
|
1451
2195
|
),
|
|
1452
2196
|
);
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
2197
|
+
// One user-clearable blocker sinks the whole batch identically — every pick
|
|
2198
|
+
// would fail the same way, and the fix is one action, not N error lines.
|
|
2199
|
+
const stale = settled.find(
|
|
2200
|
+
(r) => r.status === "rejected" && client.loginRequiredBlocker?.(r.reason),
|
|
2201
|
+
);
|
|
2202
|
+
if (stale) fail(await loginRequiredMessage(client, stale.reason, { to }));
|
|
2203
|
+
const results = await Promise.all(
|
|
2204
|
+
settled.map(async (r, i) =>
|
|
2205
|
+
r.status === "fulfilled"
|
|
2206
|
+
? r.value
|
|
2207
|
+
: {
|
|
2208
|
+
selection: picks[i],
|
|
2209
|
+
error:
|
|
2210
|
+
(await describeEngineByteFailure(client, r.reason, {
|
|
2211
|
+
backendURL: routing.backendURL,
|
|
2212
|
+
})) ??
|
|
2213
|
+
r.reason?.message ??
|
|
2214
|
+
String(r.reason),
|
|
2215
|
+
},
|
|
2216
|
+
),
|
|
1457
2217
|
);
|
|
1458
2218
|
const { formatSize } = await import(
|
|
1459
2219
|
pathToFileURL(path.join(SRC_DIR, "bridge", "image-dimensions.mjs")).href
|
|
@@ -1539,7 +2299,9 @@ async function cmdConceptImages(rest) {
|
|
|
1539
2299
|
pathToFileURL(path.join(BUILDERS_DIR, "plan.mjs")).href
|
|
1540
2300
|
);
|
|
1541
2301
|
const planState = plan.loadPlanState(deckDir);
|
|
1542
|
-
|
|
2302
|
+
// "approve" is the gate's own persisted vocabulary (stdout says "approved");
|
|
2303
|
+
// comparing against "approved" here made the note fire on every approved deck.
|
|
2304
|
+
if (planState?.decision !== "approve") {
|
|
1543
2305
|
process.stderr.write(
|
|
1544
2306
|
`slidemind: note — ${deck}'s plan is not signed off yet ` +
|
|
1545
2307
|
`(run \`slidemind plan ${deck}\`); generating concept mockups from the current plan.md anyway.\n`,
|
|
@@ -1760,7 +2522,7 @@ async function cmdPresentations(rest) {
|
|
|
1760
2522
|
}
|
|
1761
2523
|
for (const p of sorted) {
|
|
1762
2524
|
const label = p.title || p.fileName || "(untitled)";
|
|
1763
|
-
const env = p.env ? `[${p.env}]\t` : "";
|
|
2525
|
+
const env = p.env && isMultiEnv() ? `[${p.env}]\t` : "";
|
|
1764
2526
|
const state =
|
|
1765
2527
|
p.connectionState ||
|
|
1766
2528
|
(p.connected === false || p.suspendedAt ? "suspended" : "connected");
|
|
@@ -1798,20 +2560,22 @@ function publicConnectionStatus(status, client) {
|
|
|
1798
2560
|
};
|
|
1799
2561
|
}
|
|
1800
2562
|
|
|
1801
|
-
// The
|
|
1802
|
-
//
|
|
1803
|
-
//
|
|
1804
|
-
//
|
|
1805
|
-
//
|
|
1806
|
-
//
|
|
1807
|
-
//
|
|
1808
|
-
//
|
|
1809
|
-
//
|
|
1810
|
-
//
|
|
1811
|
-
//
|
|
1812
|
-
//
|
|
1813
|
-
//
|
|
1814
|
-
|
|
2563
|
+
// The readiness ladder splits in two, because the two halves fail
|
|
2564
|
+
// independently: BACKEND AUTHORING readiness (a session, a reachable add-in
|
|
2565
|
+
// backend, a supported CLI — everything render, bundle and the pane-free engine
|
|
2566
|
+
// routes need) and LIVE POWERPOINT readiness (a registered window, render-ready
|
|
2567
|
+
// metadata, an awake pane — what push/insert-slide and reading an open deck
|
|
2568
|
+
// need). Keeping them apart is the whole point of doc 13: a dead pane is no
|
|
2569
|
+
// longer "cannot work", it only defers the last mile.
|
|
2570
|
+
//
|
|
2571
|
+
// Each rung is a STRUCTURED blocker: `code` is the machine-copeable reason an
|
|
2572
|
+
// agent switches on; `actor` says who can clear it — only `cli_update_required`
|
|
2573
|
+
// is the AGENT's to fix (it can run `slidemind update`), every other rung needs
|
|
2574
|
+
// a human at the add-in. The one exception is a plugin-installed CLI:
|
|
2575
|
+
// `/plugin` is the only updater that replaces the binary on the session's PATH,
|
|
2576
|
+
// and the agent cannot drive it, so that rung hands back to the user (see
|
|
2577
|
+
// src/install-channel.mjs).
|
|
2578
|
+
function backendBlocker(pub) {
|
|
1815
2579
|
if (!pub.paired) {
|
|
1816
2580
|
return {
|
|
1817
2581
|
code: "not_paired",
|
|
@@ -1841,6 +2605,15 @@ function connectionBlocker(pub) {
|
|
|
1841
2605
|
updateCliInstruction(),
|
|
1842
2606
|
};
|
|
1843
2607
|
}
|
|
2608
|
+
return null;
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
// The live-PowerPoint half. Never gates render, bundle or the pane-free engine
|
|
2612
|
+
// routes — it only says whether the ops that genuinely type into an open deck
|
|
2613
|
+
// (push's insert, insert-slide, reading an unsaved presentation) can run now.
|
|
2614
|
+
// The `message` strings are BYTE-IDENTICAL to the pre-split ladder so doctor's
|
|
2615
|
+
// printed output never churns.
|
|
2616
|
+
function paneBlocker(pub) {
|
|
1844
2617
|
if ((pub.presentations ?? []).length === 0) {
|
|
1845
2618
|
return {
|
|
1846
2619
|
code: "no_window",
|
|
@@ -1875,12 +2648,20 @@ function connectionBlocker(pub) {
|
|
|
1875
2648
|
code: "pane_suspended",
|
|
1876
2649
|
actor: "user",
|
|
1877
2650
|
message:
|
|
1878
|
-
"Rendering is available, but pane operations are suspended. Bring PowerPoint forward and click/reopen the slidemind pane before push
|
|
2651
|
+
"Rendering is available, but pane operations are suspended. Bring PowerPoint forward and click/reopen the slidemind pane before push, insert-slide, or reading this window.",
|
|
1879
2652
|
};
|
|
1880
2653
|
}
|
|
1881
2654
|
return null;
|
|
1882
2655
|
}
|
|
1883
2656
|
|
|
2657
|
+
// The two ladders end-to-end, in the order a user hits them: nothing about a
|
|
2658
|
+
// window matters while the backend half is unmet. `slidemind ready` layers
|
|
2659
|
+
// `ambiguous_design` on top and treats `pane_suspended` as non-blocking (a
|
|
2660
|
+
// render-ready-but-suspended window is still a design source).
|
|
2661
|
+
function connectionBlocker(pub) {
|
|
2662
|
+
return backendBlocker(pub) ?? paneBlocker(pub);
|
|
2663
|
+
}
|
|
2664
|
+
|
|
1884
2665
|
// Back-compat projection for doctor: just the blocker's message, or "Ready."
|
|
1885
2666
|
// when the ladder is clear. Keeps the ladder authored in ONE place.
|
|
1886
2667
|
function connectionNextAction(pub) {
|
|
@@ -1908,6 +2689,7 @@ async function cmdSetup(rest) {
|
|
|
1908
2689
|
let target = "auto";
|
|
1909
2690
|
let doInstall = true;
|
|
1910
2691
|
let doReady = true;
|
|
2692
|
+
let doLogin = true;
|
|
1911
2693
|
let asJson = false;
|
|
1912
2694
|
for (let i = 0; i < rest.length; i++) {
|
|
1913
2695
|
const a = rest[i];
|
|
@@ -1921,6 +2703,8 @@ async function cmdSetup(rest) {
|
|
|
1921
2703
|
doInstall = false;
|
|
1922
2704
|
} else if (a === "--no-ready") {
|
|
1923
2705
|
doReady = false;
|
|
2706
|
+
} else if (a === "--no-login") {
|
|
2707
|
+
doLogin = false;
|
|
1924
2708
|
} else if (a === "--json") {
|
|
1925
2709
|
asJson = true;
|
|
1926
2710
|
} else if (a.startsWith("-")) {
|
|
@@ -1955,14 +2739,24 @@ async function cmdSetup(rest) {
|
|
|
1955
2739
|
ui.banner(pkg.version, "setup");
|
|
1956
2740
|
|
|
1957
2741
|
// --- 1. spend the claim -------------------------------------------------
|
|
1958
|
-
|
|
2742
|
+
// Whether to name the environment at all. Decided BEFORE the claim is spent
|
|
2743
|
+
// so every line in this run agrees: a first connection has nothing to
|
|
2744
|
+
// disambiguate against and stays quiet, while adding a second (or third)
|
|
2745
|
+
// environment names each one. Computing it per-line would flip mid-run, since
|
|
2746
|
+
// `upsertSession` below changes the count.
|
|
2747
|
+
const showEnv = cfg.readSessions().length > 0;
|
|
2748
|
+
await ui.section("connect", showEnv ? envLabel : "");
|
|
1959
2749
|
const sessionToken = await claims.redeemSetupClaim({
|
|
1960
2750
|
backendURL,
|
|
1961
2751
|
secret,
|
|
1962
2752
|
cliVersion: pkg.version,
|
|
1963
2753
|
});
|
|
1964
2754
|
cfg.upsertSession({ backendURL, sessionToken, label: envLabel });
|
|
1965
|
-
ui.step({
|
|
2755
|
+
ui.step({
|
|
2756
|
+
label: "authorize",
|
|
2757
|
+
value: showEnv ? envLabel : "connected",
|
|
2758
|
+
note: "session saved",
|
|
2759
|
+
});
|
|
1966
2760
|
|
|
1967
2761
|
// --- 2. make this CLI permanent ----------------------------------------
|
|
1968
2762
|
// Without this the whole thing evaporates with npx's cache: the user would be
|
|
@@ -2046,6 +2840,52 @@ async function cmdSetup(rest) {
|
|
|
2046
2840
|
bundleMod.clearBundleCache();
|
|
2047
2841
|
}
|
|
2048
2842
|
|
|
2843
|
+
// --- 4. Microsoft 365 sign-in (best-effort) -----------------------------
|
|
2844
|
+
// The pasted-claim moment is the one time the user is reliably at the
|
|
2845
|
+
// keyboard in consent headspace, so offer the browser sign-in NOW and no
|
|
2846
|
+
// later command has to interrupt a task for it. Strictly best-effort and
|
|
2847
|
+
// interactive-only: --json or no TTY (an agent ran setup — a browser tab it
|
|
2848
|
+
// cannot see must never block onboarding), --no-login, a backend that does
|
|
2849
|
+
// not honour the token, a decline or a timeout all skip WITHOUT failing
|
|
2850
|
+
// setup — the closing `ready` and the failure-time remediations still name
|
|
2851
|
+
// `slidemind login`.
|
|
2852
|
+
if (doLogin && !asJson && process.stdout.isTTY) {
|
|
2853
|
+
try {
|
|
2854
|
+
const client = await loadBridgeModule("client");
|
|
2855
|
+
if (await client.backendSupportsGraphDelegated(backendURL)) {
|
|
2856
|
+
await ui.section("sign in", "Microsoft 365");
|
|
2857
|
+
process.stderr.write(
|
|
2858
|
+
"Opening your browser to sign in to Microsoft 365 — this lets SharePoint-backed\n" +
|
|
2859
|
+
"content download with no PowerPoint window open.\n",
|
|
2860
|
+
);
|
|
2861
|
+
const graph = await loadBridgeModule("graph-auth");
|
|
2862
|
+
const res = await graph.loginGraph({
|
|
2863
|
+
backendURL,
|
|
2864
|
+
// Shorter than `slidemind login`'s: the user is mid-setup, not
|
|
2865
|
+
// parked on a sign-in they asked for — a walked-away browser tab
|
|
2866
|
+
// should not stall onboarding for five minutes.
|
|
2867
|
+
timeoutMs: 120_000,
|
|
2868
|
+
onUrl: (url) =>
|
|
2869
|
+
process.stderr.write(
|
|
2870
|
+
`If no browser opens, paste this link into one:\n ${url}\n`,
|
|
2871
|
+
),
|
|
2872
|
+
});
|
|
2873
|
+
ui.step({
|
|
2874
|
+
label: "Microsoft 365",
|
|
2875
|
+
value: res.username ?? "signed in",
|
|
2876
|
+
...(showEnv ? { note: envLabel } : {}),
|
|
2877
|
+
});
|
|
2878
|
+
}
|
|
2879
|
+
} catch {
|
|
2880
|
+
ui.step({
|
|
2881
|
+
status: "warn",
|
|
2882
|
+
label: "Microsoft 365",
|
|
2883
|
+
value: "skipped",
|
|
2884
|
+
note: "run 'slidemind login' anytime for window-free SharePoint downloads",
|
|
2885
|
+
});
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2049
2889
|
if (asJson) {
|
|
2050
2890
|
const globalInstall =
|
|
2051
2891
|
doInstall && !pluginInstall
|
|
@@ -2078,8 +2918,8 @@ async function cmdSetup(rest) {
|
|
|
2078
2918
|
|
|
2079
2919
|
ui.done(
|
|
2080
2920
|
target === "none" || (skills.length === 0 && skippedPlugins.length === 0)
|
|
2081
|
-
? `connected to ${envLabel} — run 'slidemind ready' to start`
|
|
2082
|
-
: `connected to ${envLabel} and ready to use`,
|
|
2921
|
+
? `connected${showEnv ? ` to ${envLabel}` : ""} — run 'slidemind ready' to start`
|
|
2922
|
+
: `connected${showEnv ? ` to ${envLabel}` : ""} and ready to use`,
|
|
2083
2923
|
);
|
|
2084
2924
|
|
|
2085
2925
|
if (installError) {
|
|
@@ -2097,7 +2937,7 @@ async function cmdSetup(rest) {
|
|
|
2097
2937
|
return;
|
|
2098
2938
|
}
|
|
2099
2939
|
|
|
2100
|
-
// ---
|
|
2940
|
+
// --- 5. finish the session the user actually came for -------------------
|
|
2101
2941
|
// `ready` exits non-zero for every legitimate not-yet blocker (PowerPoint
|
|
2102
2942
|
// closed, no design activated). Setup already succeeded at that point, so its
|
|
2103
2943
|
// blocker is information, not a failure — run it for the message and the
|
|
@@ -2121,6 +2961,7 @@ async function cmdReady(rest) {
|
|
|
2121
2961
|
let asJson = false;
|
|
2122
2962
|
let force = false;
|
|
2123
2963
|
let connectOnly = false;
|
|
2964
|
+
let brand = null;
|
|
2124
2965
|
const positional = [];
|
|
2125
2966
|
for (let i = 0; i < rest.length; i++) {
|
|
2126
2967
|
const a = rest[i];
|
|
@@ -2133,12 +2974,18 @@ async function cmdReady(rest) {
|
|
|
2133
2974
|
force = true;
|
|
2134
2975
|
} else if (a === "--connect-only") {
|
|
2135
2976
|
connectOnly = true;
|
|
2977
|
+
} else if (a === "--brand") {
|
|
2978
|
+
brand = rest[++i];
|
|
2979
|
+
if (!brand) fail("--brand expects a design id (presFP)");
|
|
2136
2980
|
} else if (a.startsWith("-")) {
|
|
2137
2981
|
fail(`unknown flag: ${a}`);
|
|
2138
2982
|
} else {
|
|
2139
2983
|
positional.push(a);
|
|
2140
2984
|
}
|
|
2141
2985
|
}
|
|
2986
|
+
if (brand && to) {
|
|
2987
|
+
fail("--brand and --to are mutually exclusive (a design id or a window, not both)");
|
|
2988
|
+
}
|
|
2142
2989
|
|
|
2143
2990
|
// --- Local: resolve the deck dir (never create it) and read its existing
|
|
2144
2991
|
// bundle ref. deckPresFP feeds ONLY the mirror tie-break + `suggested`; it
|
|
@@ -2228,10 +3075,213 @@ async function cmdReady(rest) {
|
|
|
2228
3075
|
return;
|
|
2229
3076
|
}
|
|
2230
3077
|
|
|
3078
|
+
// --- Backend authoring readiness beyond the connection itself: which
|
|
3079
|
+
// environments serve the pane-free engine routes. Probed at most once (and
|
|
3080
|
+
// only when a payload actually reports it), and never fatal — an add-in
|
|
3081
|
+
// without them still authors, it just relays search through a window. ---
|
|
3082
|
+
let engineInfo = null;
|
|
3083
|
+
const engines = async () => {
|
|
3084
|
+
if (!engineInfo) {
|
|
3085
|
+
try {
|
|
3086
|
+
engineInfo = await client.engineReachability({
|
|
3087
|
+
backends: statusRaw.backends,
|
|
3088
|
+
});
|
|
3089
|
+
} catch {
|
|
3090
|
+
engineInfo = { available: false, envs: [] };
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
return engineInfo;
|
|
3094
|
+
};
|
|
3095
|
+
|
|
3096
|
+
// Microsoft 365 sign-in, surfaced HERE because ready is the agent's
|
|
3097
|
+
// session-start preflight and browser SSO is the one step an agent cannot do
|
|
3098
|
+
// itself — it has to ask its human, and this is where it learns to. Reported
|
|
3099
|
+
// only when a backend advertises graphDelegated (older backends have no
|
|
3100
|
+
// window-free SharePoint path, so there is nothing to sign in FOR).
|
|
3101
|
+
// On a cliLoginV1 backend the sign-in is REQUIRED for the whole pane-free
|
|
3102
|
+
// engine surface (catalog, search, downloads, extraction answer 401
|
|
3103
|
+
// login_required without it) — so the degradation says that plainly and up
|
|
3104
|
+
// front, before the agent's first search meets the 401. Render and bundle
|
|
3105
|
+
// still never need it. null = no backend advertises it.
|
|
3106
|
+
let m365Info;
|
|
3107
|
+
const m365 = async () => {
|
|
3108
|
+
if (m365Info !== undefined) return m365Info;
|
|
3109
|
+
const advertising = (statusRaw.backends ?? []).filter(
|
|
3110
|
+
(b) => b.ok && b.capabilities?.graphDelegated === true && b.backendURL,
|
|
3111
|
+
);
|
|
3112
|
+
if (advertising.length === 0) {
|
|
3113
|
+
m365Info = null;
|
|
3114
|
+
return m365Info;
|
|
3115
|
+
}
|
|
3116
|
+
const graph = await loadBridgeModule("graph-auth");
|
|
3117
|
+
const signedOut = advertising.filter(
|
|
3118
|
+
(b) => !graph.hasGraphSession(b.backendURL),
|
|
3119
|
+
);
|
|
3120
|
+
const signedOutEnvs = signedOut.map((b) => cfg.deriveEnvLabel(b.backendURL));
|
|
3121
|
+
m365Info =
|
|
3122
|
+
signedOut.length === 0
|
|
3123
|
+
? { signedIn: true, degradation: null, line: "Microsoft 365: signed in" }
|
|
3124
|
+
: {
|
|
3125
|
+
signedIn: false,
|
|
3126
|
+
degradation: {
|
|
3127
|
+
code: "login_required",
|
|
3128
|
+
actor: "user",
|
|
3129
|
+
envs: signedOutEnvs,
|
|
3130
|
+
message:
|
|
3131
|
+
"Microsoft 365: not signed in — engine search, downloads and slide " +
|
|
3132
|
+
"extraction run as the USER. The first engine command opens a browser " +
|
|
3133
|
+
"sign-in AUTOMATICALLY and waits; tell the user to complete it there " +
|
|
3134
|
+
"(you cannot). Render, build and bundle work without it.",
|
|
3135
|
+
},
|
|
3136
|
+
line:
|
|
3137
|
+
`Microsoft 365: not signed in${envTag(signedOutEnvs.join(", "))} — the next engine ` +
|
|
3138
|
+
"command opens a browser sign-in for the user to complete (automatic; " +
|
|
3139
|
+
"`slidemind login` also works any time).",
|
|
3140
|
+
};
|
|
3141
|
+
return m365Info;
|
|
3142
|
+
};
|
|
3143
|
+
|
|
3144
|
+
// --- A design resolved WITHOUT a window — the deck's own binding, an
|
|
3145
|
+
// explicit --brand, or the backend's brand list. Same payload either way:
|
|
3146
|
+
// ready, `windowless: true`, and a pane status that is INFORMATIONAL. What
|
|
3147
|
+
// the missing window actually costs depends on the engine routes: with them
|
|
3148
|
+
// only push/insert-slide and reading an open presentation need PowerPoint.
|
|
3149
|
+
//
|
|
3150
|
+
// "Windowless" is about how the DESIGN was resolved, not about the registry:
|
|
3151
|
+
// --brand runs before the registry is consulted at all, and the later rungs
|
|
3152
|
+
// fire on "nothing render-ready", which a connected-but-still-matching window
|
|
3153
|
+
// satisfies. So the pane status is DERIVED from the registry as it stands at
|
|
3154
|
+
// call time (the hydration wait reassigns it), and the prose only claims
|
|
3155
|
+
// "no PowerPoint window" when there genuinely is none. ---
|
|
3156
|
+
const finishWindowless = async (result) => {
|
|
3157
|
+
if (deckDir) {
|
|
3158
|
+
bundle.writeDeckBundleRef(deckDir, {
|
|
3159
|
+
presFP: result.presFP,
|
|
3160
|
+
etag: result.etag,
|
|
3161
|
+
});
|
|
3162
|
+
}
|
|
3163
|
+
const designName = readDesignName(result.dir);
|
|
3164
|
+
const env = result.backendURL ? cfg.deriveEnvLabel(result.backendURL) : null;
|
|
3165
|
+
const eng = await engines();
|
|
3166
|
+
const m = await m365();
|
|
3167
|
+
const paneReady = registry.some(client.isPaneReadyPresentation);
|
|
3168
|
+
const noWindow = registry.length === 0;
|
|
3169
|
+
const nextAction = noWindow
|
|
3170
|
+
? eng.available
|
|
3171
|
+
? "Ready (no PowerPoint window — authoring, engine search and build-pptx all work; only push/insert-slide and reading an open presentation need one)."
|
|
3172
|
+
: "Ready (no PowerPoint window — build-pptx/build/review/view-draft work; push/search/read need a pane)."
|
|
3173
|
+
: paneReady
|
|
3174
|
+
? "Ready (design resolved without targeting a window; a registered PowerPoint window is available for push/insert-slide)."
|
|
3175
|
+
: eng.available
|
|
3176
|
+
? "Ready (design resolved without targeting a window; pane operations — push/insert-slide/read of a window — will wait for a pane click; engine search does not)."
|
|
3177
|
+
: "Ready (design resolved without targeting a window; pane operations — push/search/read — will wait for a pane click).";
|
|
3178
|
+
if (asJson) {
|
|
3179
|
+
process.stdout.write(
|
|
3180
|
+
JSON.stringify(
|
|
3181
|
+
{
|
|
3182
|
+
ready: true,
|
|
3183
|
+
windowless: true,
|
|
3184
|
+
deck: deckSlug,
|
|
3185
|
+
bound: Boolean(deckDir),
|
|
3186
|
+
env,
|
|
3187
|
+
presId: null,
|
|
3188
|
+
presFP: result.presFP,
|
|
3189
|
+
designName,
|
|
3190
|
+
bundleDir: result.dir,
|
|
3191
|
+
bundleEtag: result.etag ?? null,
|
|
3192
|
+
paneSuspended: !paneReady,
|
|
3193
|
+
engines: eng,
|
|
3194
|
+
...(m ? { m365: { signedIn: m.signedIn, degradation: m.degradation } } : {}),
|
|
3195
|
+
warnings: result.warnings ?? [],
|
|
3196
|
+
nextAction,
|
|
3197
|
+
},
|
|
3198
|
+
null,
|
|
3199
|
+
2,
|
|
3200
|
+
) + "\n",
|
|
3201
|
+
);
|
|
3202
|
+
return;
|
|
3203
|
+
}
|
|
3204
|
+
const designLabel = designName
|
|
3205
|
+
? `${designName} (${result.presFP})`
|
|
3206
|
+
: result.presFP;
|
|
3207
|
+
process.stdout.write(
|
|
3208
|
+
`Ready — design ${designLabel}${envTag(env)}${noWindow ? " (no PowerPoint window)" : ""}\n`,
|
|
3209
|
+
);
|
|
3210
|
+
process.stdout.write(` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`);
|
|
3211
|
+
if (deckDir) process.stdout.write(` deck ${deckSlug} bound\n`);
|
|
3212
|
+
process.stdout.write(
|
|
3213
|
+
noWindow
|
|
3214
|
+
? eng.available
|
|
3215
|
+
? " Note: authoring, engine search and build-pptx need no window; push/insert-slide and reading an open presentation do.\n"
|
|
3216
|
+
: " Note: build-pptx/build/review/view-draft work without a window; push/search/read need a pane.\n"
|
|
3217
|
+
: paneReady
|
|
3218
|
+
? " Note: the design was resolved without targeting a window; a registered PowerPoint window is available for push/insert-slide.\n"
|
|
3219
|
+
: eng.available
|
|
3220
|
+
? " Note: pane operations (push/insert-slide/read of a window) will wait for a pane click; engine search does not.\n"
|
|
3221
|
+
: " Note: pane operations (push/search/read) will wait for a pane click.\n",
|
|
3222
|
+
);
|
|
3223
|
+
if (m) process.stdout.write(` ${m.line}\n`);
|
|
3224
|
+
for (const w of result.warnings ?? []) {
|
|
3225
|
+
process.stderr.write(` warning: ${w}\n`);
|
|
3226
|
+
}
|
|
3227
|
+
};
|
|
3228
|
+
|
|
3229
|
+
// Materialize a design by id over the pane-free brand route. Returns null on
|
|
3230
|
+
// any miss (old add-in, unknown/revoked design) so the caller can fall
|
|
3231
|
+
// through to the window ladder instead of dead-ending.
|
|
3232
|
+
const materializeBrand = async (brandId) => {
|
|
3233
|
+
try {
|
|
3234
|
+
return await bundle.materializeBundle(null, { force, brandId });
|
|
3235
|
+
} catch {
|
|
3236
|
+
return null;
|
|
3237
|
+
}
|
|
3238
|
+
};
|
|
3239
|
+
|
|
3240
|
+
// --- --brand <presFP>: the user (or the agent, after an ambiguous brand
|
|
3241
|
+
// list) named the design outright. It resolves with or without a window, so
|
|
3242
|
+
// it runs before the registry is consulted at all. ---
|
|
3243
|
+
if (brand) {
|
|
3244
|
+
const result = await materializeBrand(brand);
|
|
3245
|
+
if (!result) {
|
|
3246
|
+
finishBlocker({
|
|
3247
|
+
blocker: {
|
|
3248
|
+
code: "unknown_brand",
|
|
3249
|
+
actor: "user",
|
|
3250
|
+
message: `No connected slidemind add-in serves design '${brand}'. Run 'slidemind ready' with no --brand to see which designs are available.`,
|
|
3251
|
+
},
|
|
3252
|
+
});
|
|
3253
|
+
return;
|
|
3254
|
+
}
|
|
3255
|
+
await finishWindowless(result);
|
|
3256
|
+
return;
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
const isRR = client.isRenderReadyPresentation;
|
|
3260
|
+
|
|
3261
|
+
// --- Bound-deck ladder: session → bundle freshness, no pane precondition.
|
|
3262
|
+
// The design identity (presFP == brandId) lives in the deck's
|
|
3263
|
+
// .bundle-ref.json and the bundle syncs over the pane-free brand route, so
|
|
3264
|
+
// with PowerPoint CLOSED (an empty registry) a bound deck readies straight
|
|
3265
|
+
// away instead of sitting out the hydration wait for a window that isn't
|
|
3266
|
+
// coming. A registry that holds windows still hydrates first — the open
|
|
3267
|
+
// add-in stays the source of truth whenever there IS one. Skipped for
|
|
3268
|
+
// --connect-only (which resolves no design at all) and for --to (an explicit
|
|
3269
|
+
// window must resolve through the registry); a miss (old add-in, legacy ref,
|
|
3270
|
+
// revoked grant) falls through to the window ladder. ---
|
|
3271
|
+
const boundDeckLadder = deckDir && deckPresFP && !to && !connectOnly;
|
|
3272
|
+
let boundRungTried = false;
|
|
3273
|
+
if (boundDeckLadder && registry.length === 0) {
|
|
3274
|
+
boundRungTried = true;
|
|
3275
|
+
const result = await materializeBrand(deckPresFP);
|
|
3276
|
+
if (result) {
|
|
3277
|
+
await finishWindowless(result);
|
|
3278
|
+
return;
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
|
|
2231
3282
|
// --- Bounded hydration wait when nothing is render-ready yet: covers the
|
|
2232
3283
|
// just-clicked / just-woke pane still hydrating brand metadata. Polls even on
|
|
2233
3284
|
// an empty registry (that's the flaky window this papers over). ---
|
|
2234
|
-
const isRR = client.isRenderReadyPresentation;
|
|
2235
3285
|
if (!registry.some(isRR)) {
|
|
2236
3286
|
const waitMs =
|
|
2237
3287
|
parseInt(process.env.SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS || "6000", 10) ||
|
|
@@ -2245,74 +3295,92 @@ async function cmdReady(rest) {
|
|
|
2245
3295
|
}
|
|
2246
3296
|
}
|
|
2247
3297
|
if (!registry.some(isRR)) {
|
|
2248
|
-
//
|
|
2249
|
-
//
|
|
2250
|
-
//
|
|
2251
|
-
//
|
|
2252
|
-
//
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
3298
|
+
// --connect-only asks one question — "can I work?" — and since the engine
|
|
3299
|
+
// routes answer off the session, a backend that serves them IS the answer:
|
|
3300
|
+
// discovery, search and every per-result fetch run with PowerPoint closed.
|
|
3301
|
+
// Without them search is still relayed through a pane, so the window rung
|
|
3302
|
+
// below stands.
|
|
3303
|
+
if (connectOnly && (await engines()).available) {
|
|
3304
|
+
const eng = await engines();
|
|
3305
|
+
const m = await m365();
|
|
3306
|
+
if (asJson) {
|
|
3307
|
+
process.stdout.write(
|
|
3308
|
+
JSON.stringify(
|
|
3309
|
+
{
|
|
3310
|
+
ready: true,
|
|
3311
|
+
connectOnly: true,
|
|
3312
|
+
windowless: true,
|
|
3313
|
+
engines: eng,
|
|
3314
|
+
...(m ? { m365: { signedIn: m.signedIn, degradation: m.degradation } } : {}),
|
|
3315
|
+
nextAction:
|
|
3316
|
+
"Ready (no PowerPoint window — engine search runs off the connection; only insert-slide needs one).",
|
|
3317
|
+
backends: (pub.backends ?? []).map((b) => ({ env: b.env, ok: b.ok })),
|
|
3318
|
+
},
|
|
3319
|
+
null,
|
|
3320
|
+
2,
|
|
3321
|
+
) + "\n",
|
|
3322
|
+
);
|
|
3323
|
+
} else {
|
|
3324
|
+
process.stdout.write(
|
|
3325
|
+
`Connected. ${pub.backendCount} backend(s), 0 window(s) registered — ` +
|
|
3326
|
+
`engine search runs without one${envTag(eng.envs.join(", "))}.\n`,
|
|
3327
|
+
);
|
|
3328
|
+
if (m) process.stdout.write(` ${m.line}\n`);
|
|
3329
|
+
}
|
|
3330
|
+
return;
|
|
3331
|
+
}
|
|
3332
|
+
// The bound-deck ladder again, for the windows-registered-but-never-ready
|
|
3333
|
+
// case the pre-poll rung deliberately left to the hydration wait.
|
|
3334
|
+
if (boundDeckLadder && !boundRungTried) {
|
|
3335
|
+
boundRungTried = true;
|
|
3336
|
+
const result = await materializeBrand(deckPresFP);
|
|
3337
|
+
if (result) {
|
|
3338
|
+
await finishWindowless(result);
|
|
3339
|
+
return;
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
// --- Unbound + no window: ask the backend which designs this user may
|
|
3343
|
+
// build against. One is the answer; several genuinely need a human choice,
|
|
3344
|
+
// which --brand records. A backend without the route (or a deck already
|
|
3345
|
+
// bound to a design the brand route just refused) falls through unchanged. ---
|
|
3346
|
+
if (!connectOnly && !to && !deckPresFP && !boundRungTried) {
|
|
3347
|
+
let brands = [];
|
|
2257
3348
|
try {
|
|
2258
|
-
|
|
2259
|
-
force,
|
|
2260
|
-
brandId: deckPresFP,
|
|
2261
|
-
});
|
|
3349
|
+
brands = await client.listBrands();
|
|
2262
3350
|
} catch {
|
|
2263
|
-
/*
|
|
3351
|
+
/* no brand list — the window ladder below is the answer */
|
|
2264
3352
|
}
|
|
2265
|
-
if (
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
});
|
|
2270
|
-
const designName = readDesignName(result.dir);
|
|
2271
|
-
const env = result.backendURL
|
|
2272
|
-
? cfg.deriveEnvLabel(result.backendURL)
|
|
2273
|
-
: null;
|
|
2274
|
-
const nextAction =
|
|
2275
|
-
"Ready (no PowerPoint window — build-pptx/build/review/view-draft work; push/search/read need a pane).";
|
|
2276
|
-
if (asJson) {
|
|
2277
|
-
process.stdout.write(
|
|
2278
|
-
JSON.stringify(
|
|
2279
|
-
{
|
|
2280
|
-
ready: true,
|
|
2281
|
-
windowless: true,
|
|
2282
|
-
deck: deckSlug,
|
|
2283
|
-
bound: true,
|
|
2284
|
-
env,
|
|
2285
|
-
presId: null,
|
|
2286
|
-
presFP: result.presFP,
|
|
2287
|
-
designName,
|
|
2288
|
-
bundleDir: result.dir,
|
|
2289
|
-
bundleEtag: result.etag ?? null,
|
|
2290
|
-
paneSuspended: true,
|
|
2291
|
-
warnings: result.warnings ?? [],
|
|
2292
|
-
nextAction,
|
|
2293
|
-
},
|
|
2294
|
-
null,
|
|
2295
|
-
2,
|
|
2296
|
-
) + "\n",
|
|
2297
|
-
);
|
|
3353
|
+
if (brands.length === 1) {
|
|
3354
|
+
const result = await materializeBrand(brands[0].presFP);
|
|
3355
|
+
if (result) {
|
|
3356
|
+
await finishWindowless(result);
|
|
2298
3357
|
return;
|
|
2299
3358
|
}
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
process.stdout.write(
|
|
2304
|
-
`Ready — design ${designLabel}${env ? ` [${env}]` : ""} (no PowerPoint window)\n`,
|
|
2305
|
-
);
|
|
2306
|
-
process.stdout.write(
|
|
2307
|
-
` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`,
|
|
2308
|
-
);
|
|
2309
|
-
process.stdout.write(` deck ${deckSlug} bound\n`);
|
|
2310
|
-
process.stdout.write(
|
|
2311
|
-
" Note: build-pptx/build/review/view-draft work without a window; push/search/read need a pane.\n",
|
|
3359
|
+
} else if (brands.length > 1) {
|
|
3360
|
+
const rows = brands.map(
|
|
3361
|
+
(b) => ` ${b.presFP}${envTag(b.env)} ${b.name ?? ""}`.trimEnd(),
|
|
2312
3362
|
);
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
3363
|
+
const blocker = {
|
|
3364
|
+
code: "ambiguous_brand",
|
|
3365
|
+
actor: "user",
|
|
3366
|
+
message:
|
|
3367
|
+
"Several corporate designs are available and no PowerPoint window says which — ask the user which to build against, then re-run: slidemind ready" +
|
|
3368
|
+
`${deckSlug ? ` ${deckSlug}` : ""} --brand <presFP>.`,
|
|
3369
|
+
};
|
|
3370
|
+
finishBlocker({
|
|
3371
|
+
blocker,
|
|
3372
|
+
extras: {
|
|
3373
|
+
brands: brands.map((b) => ({
|
|
3374
|
+
presFP: b.presFP,
|
|
3375
|
+
name: b.name ?? null,
|
|
3376
|
+
env: b.env ?? null,
|
|
3377
|
+
})),
|
|
3378
|
+
},
|
|
3379
|
+
humanLines:
|
|
3380
|
+
"slidemind: several corporate designs are available — ask the user which to build against:\n" +
|
|
3381
|
+
`${rows.join("\n")}\n` +
|
|
3382
|
+
`Then re-run: slidemind ready${deckSlug ? ` ${deckSlug}` : ""} --brand <presFP>`,
|
|
3383
|
+
});
|
|
2316
3384
|
return;
|
|
2317
3385
|
}
|
|
2318
3386
|
}
|
|
@@ -2339,12 +3407,15 @@ async function cmdReady(rest) {
|
|
|
2339
3407
|
// --- --connect-only: the connection is up with ≥1 render-ready window; the
|
|
2340
3408
|
// search skill needs nothing more (no design resolution, no bundle). ---
|
|
2341
3409
|
if (connectOnly) {
|
|
3410
|
+
const m = await m365();
|
|
2342
3411
|
if (asJson) {
|
|
2343
3412
|
process.stdout.write(
|
|
2344
3413
|
JSON.stringify(
|
|
2345
3414
|
{
|
|
2346
3415
|
ready: true,
|
|
2347
3416
|
connectOnly: true,
|
|
3417
|
+
engines: await engines(),
|
|
3418
|
+
...(m ? { m365: { signedIn: m.signedIn, degradation: m.degradation } } : {}),
|
|
2348
3419
|
nextAction: "Ready.",
|
|
2349
3420
|
backends: (pub.backends ?? []).map((b) => ({ env: b.env, ok: b.ok })),
|
|
2350
3421
|
},
|
|
@@ -2357,6 +3428,7 @@ async function cmdReady(rest) {
|
|
|
2357
3428
|
process.stdout.write(
|
|
2358
3429
|
`Connected. ${pub.backendCount} backend(s), ${windows} window(s) registered.\n`,
|
|
2359
3430
|
);
|
|
3431
|
+
if (m) process.stdout.write(` ${m.line}\n`);
|
|
2360
3432
|
}
|
|
2361
3433
|
return;
|
|
2362
3434
|
}
|
|
@@ -2395,7 +3467,7 @@ async function cmdReady(rest) {
|
|
|
2395
3467
|
};
|
|
2396
3468
|
const rows = windows.map(
|
|
2397
3469
|
(p) =>
|
|
2398
|
-
` ${p.presId}
|
|
3470
|
+
` ${p.presId} ${envTag(p.env)} ${p.title || p.fileName || "(untitled)"}`,
|
|
2399
3471
|
);
|
|
2400
3472
|
finishBlocker({
|
|
2401
3473
|
blocker,
|
|
@@ -2416,7 +3488,7 @@ async function cmdReady(rest) {
|
|
|
2416
3488
|
"Multiple corporate designs are open — ask the user which to build against, then re-run: slidemind ready --to <presId>.",
|
|
2417
3489
|
};
|
|
2418
3490
|
const rows = cand.map((c) => {
|
|
2419
|
-
const env = c.env ? `
|
|
3491
|
+
const env = envTag(c.env) ? ` ${envTag(c.env)}` : "";
|
|
2420
3492
|
const label = c.title || c.fileName || "(untitled)";
|
|
2421
3493
|
return ` ${c.presId}${env} ${label} (design ${c.brandId ?? "?"})`;
|
|
2422
3494
|
});
|
|
@@ -2468,6 +3540,8 @@ async function cmdReady(rest) {
|
|
|
2468
3540
|
|
|
2469
3541
|
const designName = readDesignName(result.dir);
|
|
2470
3542
|
const paneSuspended = !client.isPaneReadyPresentation(entry);
|
|
3543
|
+
const engineInfoForWindow = await engines();
|
|
3544
|
+
const m365ForWindow = await m365();
|
|
2471
3545
|
|
|
2472
3546
|
if (asJson) {
|
|
2473
3547
|
process.stdout.write(
|
|
@@ -2487,6 +3561,15 @@ async function cmdReady(rest) {
|
|
|
2487
3561
|
fileName: entry.fileName ?? null,
|
|
2488
3562
|
},
|
|
2489
3563
|
paneSuspended,
|
|
3564
|
+
engines: engineInfoForWindow,
|
|
3565
|
+
...(m365ForWindow
|
|
3566
|
+
? {
|
|
3567
|
+
m365: {
|
|
3568
|
+
signedIn: m365ForWindow.signedIn,
|
|
3569
|
+
degradation: m365ForWindow.degradation,
|
|
3570
|
+
},
|
|
3571
|
+
}
|
|
3572
|
+
: {}),
|
|
2490
3573
|
warnings: result.warnings ?? [],
|
|
2491
3574
|
nextAction: "Ready.",
|
|
2492
3575
|
},
|
|
@@ -2498,22 +3581,25 @@ async function cmdReady(rest) {
|
|
|
2498
3581
|
}
|
|
2499
3582
|
|
|
2500
3583
|
// --- Human success: compact multi-line summary on stdout. ---
|
|
2501
|
-
const
|
|
3584
|
+
const entryEnv = envTag(entry.env);
|
|
2502
3585
|
const presLabel = entry.title || entry.fileName || "(untitled)";
|
|
2503
3586
|
const designLabel = designName
|
|
2504
3587
|
? `${designName} (${result.presFP})`
|
|
2505
3588
|
: result.presFP;
|
|
2506
3589
|
process.stdout.write(`Ready — design ${designLabel}\n`);
|
|
2507
|
-
process.stdout.write(` presentation: ${presLabel}${
|
|
3590
|
+
process.stdout.write(` presentation: ${presLabel}${entryEnv}\n`);
|
|
2508
3591
|
process.stdout.write(
|
|
2509
3592
|
` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`,
|
|
2510
3593
|
);
|
|
2511
3594
|
if (bound) process.stdout.write(` deck ${deckSlug} bound\n`);
|
|
2512
3595
|
if (paneSuspended) {
|
|
2513
3596
|
process.stdout.write(
|
|
2514
|
-
|
|
3597
|
+
engineInfoForWindow.available
|
|
3598
|
+
? " Note: pane operations (push/insert-slide/read of this window) will wait for a pane click; engine search does not.\n"
|
|
3599
|
+
: " Note: pane operations (push/search/read) will wait for a pane click.\n",
|
|
2515
3600
|
);
|
|
2516
3601
|
}
|
|
3602
|
+
if (m365ForWindow) process.stdout.write(` ${m365ForWindow.line}\n`);
|
|
2517
3603
|
for (const w of result.warnings ?? []) {
|
|
2518
3604
|
process.stderr.write(` warning: ${w}\n`);
|
|
2519
3605
|
}
|
|
@@ -2530,11 +3616,14 @@ async function cmdDoctor(rest) {
|
|
|
2530
3616
|
// still reaches the reader, as the `cli_update_required` rung of the ladder.
|
|
2531
3617
|
client.setDiagnosticMode(true);
|
|
2532
3618
|
if (sessions.length === 0) {
|
|
3619
|
+
const blocker = backendBlocker({ paired: false });
|
|
2533
3620
|
const pub = {
|
|
2534
3621
|
paired: false,
|
|
2535
3622
|
backendCount: 0,
|
|
2536
3623
|
backends: [],
|
|
2537
3624
|
presentations: [],
|
|
3625
|
+
authoring: { ready: false, blocker, degraded: null, engines: null },
|
|
3626
|
+
host: { ready: false, blocker: paneBlocker({ presentations: [] }) },
|
|
2538
3627
|
nextAction: connectionNextAction({ paired: false }),
|
|
2539
3628
|
};
|
|
2540
3629
|
process[asJson ? "stdout" : "stderr"].write(
|
|
@@ -2542,7 +3631,29 @@ async function cmdDoctor(rest) {
|
|
|
2542
3631
|
);
|
|
2543
3632
|
return;
|
|
2544
3633
|
}
|
|
2545
|
-
const
|
|
3634
|
+
const statusRaw = await client.connectionStatus();
|
|
3635
|
+
const status = publicConnectionStatus(statusRaw, client);
|
|
3636
|
+
// The two ladders are INDEPENDENT: authoring (session → reachable backend →
|
|
3637
|
+
// CLI version → engine reachability) is what render, bundle and the pane-free
|
|
3638
|
+
// engine routes ride on, while the live-PowerPoint ladder only governs
|
|
3639
|
+
// push/insert-slide and reading an open presentation. Reporting them
|
|
3640
|
+
// separately is the point — a dead pane is not "cannot work".
|
|
3641
|
+
const authoringBlocker = backendBlocker(status);
|
|
3642
|
+
let engines = { available: false, envs: [] };
|
|
3643
|
+
if (!authoringBlocker) {
|
|
3644
|
+
try {
|
|
3645
|
+
engines = await client.engineReachability({ backends: statusRaw.backends });
|
|
3646
|
+
} catch {
|
|
3647
|
+
/* leave it as unavailable — never fail a diagnosis on a probe */
|
|
3648
|
+
}
|
|
3649
|
+
}
|
|
3650
|
+
status.authoring = {
|
|
3651
|
+
ready: !authoringBlocker,
|
|
3652
|
+
blocker: authoringBlocker,
|
|
3653
|
+
engines,
|
|
3654
|
+
};
|
|
3655
|
+
const hostBlocker = paneBlocker(status);
|
|
3656
|
+
status.host = { ready: !hostBlocker, blocker: hostBlocker };
|
|
2546
3657
|
status.nextAction = connectionNextAction(status);
|
|
2547
3658
|
if (asJson) {
|
|
2548
3659
|
process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
|
|
@@ -2556,6 +3667,41 @@ async function cmdDoctor(rest) {
|
|
|
2556
3667
|
`\n`,
|
|
2557
3668
|
);
|
|
2558
3669
|
}
|
|
3670
|
+
process.stdout.write(
|
|
3671
|
+
`Backend authoring ready: ${status.authoring.ready ? "yes" : "no"}` +
|
|
3672
|
+
`${status.authoring.ready ? "" : ` — ${authoringBlocker.message}`}\n`,
|
|
3673
|
+
);
|
|
3674
|
+
process.stdout.write(
|
|
3675
|
+
` engine search (no window needed): ${
|
|
3676
|
+
engines.available
|
|
3677
|
+
? `yes [${engines.envs.join(", ")}]`
|
|
3678
|
+
: "no — relayed through an open pane"
|
|
3679
|
+
}\n`,
|
|
3680
|
+
);
|
|
3681
|
+
// Human output only — doctor's --json shape is a contract and the m365 state
|
|
3682
|
+
// already rides `ready --json`. Printed only when a backend can actually
|
|
3683
|
+
// spend a sign-in (graphDelegated), so old deployments stay noise-free.
|
|
3684
|
+
const graphAdvertising = (statusRaw.backends ?? []).filter(
|
|
3685
|
+
(b) => b.ok && b.capabilities?.graphDelegated === true && b.backendURL,
|
|
3686
|
+
);
|
|
3687
|
+
if (graphAdvertising.length > 0) {
|
|
3688
|
+
const graph = await loadBridgeModule("graph-auth");
|
|
3689
|
+
const signedOut = graphAdvertising.filter(
|
|
3690
|
+
(b) => !graph.hasGraphSession(b.backendURL),
|
|
3691
|
+
);
|
|
3692
|
+
const signedOutEnvs = signedOut.map((b) => cfg.deriveEnvLabel(b.backendURL));
|
|
3693
|
+
process.stdout.write(
|
|
3694
|
+
signedOut.length === 0
|
|
3695
|
+
? " Microsoft 365: signed in\n"
|
|
3696
|
+
: ` Microsoft 365: not signed in [${signedOutEnvs.join(", ")}] — engine search and ` +
|
|
3697
|
+
"downloads run as the user; the next engine command opens a browser " +
|
|
3698
|
+
"sign-in for them to complete (or run 'slidemind login' now)\n",
|
|
3699
|
+
);
|
|
3700
|
+
}
|
|
3701
|
+
process.stdout.write(
|
|
3702
|
+
`Live PowerPoint ready: ${status.host.ready ? "yes" : "no"}` +
|
|
3703
|
+
`${status.host.ready ? "" : ` — ${hostBlocker.message}`}\n`,
|
|
3704
|
+
);
|
|
2559
3705
|
if (status.presentations.length === 0) {
|
|
2560
3706
|
process.stdout.write("Open presentations: none\n");
|
|
2561
3707
|
} else {
|
|
@@ -2697,16 +3843,88 @@ async function loadBridgeModule(name) {
|
|
|
2697
3843
|
return import(pathToFileURL(path.join(SRC_DIR, "bridge", `${name}.mjs`)).href);
|
|
2698
3844
|
}
|
|
2699
3845
|
|
|
3846
|
+
// The one line every "you are not connected" path speaks, so the remedy reads
|
|
3847
|
+
// the same wherever a user meets it.
|
|
3848
|
+
const NOT_CONNECTED_LINE =
|
|
3849
|
+
"Not connected. In PowerPoint open slidemind → Settings → General → Connect CLI, press the link button, and run the command it shows.";
|
|
3850
|
+
|
|
3851
|
+
// Match a `--backend`-style argument against a paired session the way logout and
|
|
3852
|
+
// use do: exact URL, stored label, derived env label, or a substring of the URL.
|
|
3853
|
+
function matchesBackend(cfg, session, want) {
|
|
3854
|
+
return (
|
|
3855
|
+
session.backendURL === want ||
|
|
3856
|
+
session.label === want ||
|
|
3857
|
+
cfg.deriveEnvLabel(session.backendURL) === want ||
|
|
3858
|
+
session.backendURL.includes(want)
|
|
3859
|
+
);
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3862
|
+
// `slidemind login [--backend <url|env>]` — sign this CLI in to Microsoft 365 so
|
|
3863
|
+
// the pane-free engine routes can fetch SharePoint-backed content with the
|
|
3864
|
+
// user's OWN identity (docs/slidemind-cli-connect/15-graph-delegated-cli.md).
|
|
3865
|
+
// Separate from the CLI session on purpose: that one is minted by the add-in for
|
|
3866
|
+
// whoever pressed Connect CLI, this one is an Entra token for whoever completes
|
|
3867
|
+
// the browser sign-in, and only the second may read a user's SharePoint content.
|
|
3868
|
+
// Everything else — render, bundle, whoami — is unaffected either way.
|
|
3869
|
+
async function cmdLogin(rest) {
|
|
3870
|
+
const stray = rest.find((a) => a.startsWith("-"));
|
|
3871
|
+
if (stray) {
|
|
3872
|
+
fail(`unexpected argument: ${stray}\nusage: slidemind login [--backend <url|env>]`);
|
|
3873
|
+
}
|
|
3874
|
+
const cfg = await loadBridgeModule("config");
|
|
3875
|
+
const sessions = cfg.readSessions();
|
|
3876
|
+
if (sessions.length === 0) fail(NOT_CONNECTED_LINE);
|
|
3877
|
+
// A browser flow targets ONE environment — there is nothing to fan out. The
|
|
3878
|
+
// scope flag picks it; otherwise the default environment does, and the closing
|
|
3879
|
+
// line names the others so nothing looks silently skipped.
|
|
3880
|
+
const want = (process.env.SLIDEMIND_BACKEND || "").trim();
|
|
3881
|
+
let target;
|
|
3882
|
+
if (want) {
|
|
3883
|
+
target = sessions.find((s) => matchesBackend(cfg, s, want));
|
|
3884
|
+
if (!target) {
|
|
3885
|
+
fail(`not paired with '${want}'. Run 'slidemind whoami' to list environments.`);
|
|
3886
|
+
}
|
|
3887
|
+
} else {
|
|
3888
|
+
const def = cfg.getDefaultBackend();
|
|
3889
|
+
target = sessions.find((s) => s.backendURL === def) ?? sessions[0];
|
|
3890
|
+
}
|
|
3891
|
+
const graph = await loadBridgeModule("graph-auth");
|
|
3892
|
+
// This command is routinely typed by an AI agent on the human's machine, and
|
|
3893
|
+
// the browser is where the agent's reach ends — say the handoff out loud.
|
|
3894
|
+
process.stderr.write(
|
|
3895
|
+
`Opening your browser to sign in to Microsoft 365${envPhrase(target.label, "for")}…\n` +
|
|
3896
|
+
"A HUMAN must complete this step (agents: ask your user to finish the " +
|
|
3897
|
+
"sign-in in the browser window that just opened).\n",
|
|
3898
|
+
);
|
|
3899
|
+
const res = await graph.loginGraph({
|
|
3900
|
+
backendURL: target.backendURL,
|
|
3901
|
+
// Printed before the opener runs: on a machine where no opener works this
|
|
3902
|
+
// link is the only way through, and it is useless after the wait times out.
|
|
3903
|
+
onUrl: (url) =>
|
|
3904
|
+
process.stderr.write(`If no browser opens, paste this link into one:\n ${url}\n`),
|
|
3905
|
+
});
|
|
3906
|
+
process.stdout.write(
|
|
3907
|
+
`Signed in to Microsoft 365 as ${res.username ?? "your account"}${envPhrase(target.label, "for")}.\n` +
|
|
3908
|
+
"SharePoint-backed slides and images now download with no PowerPoint window open.\n",
|
|
3909
|
+
);
|
|
3910
|
+
const others = sessions.filter((s) => s.backendURL !== target.backendURL);
|
|
3911
|
+
if (others.length > 0) {
|
|
3912
|
+
process.stdout.write(
|
|
3913
|
+
`Still signed out: ${others.map((s) => s.label).join(", ")} ` +
|
|
3914
|
+
"(each needs its own 'slidemind login --backend <env>').\n",
|
|
3915
|
+
);
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
|
|
2700
3919
|
async function cmdWhoami() {
|
|
2701
3920
|
const cfg = await loadBridgeModule("config");
|
|
2702
3921
|
const sessions = cfg.readSessions();
|
|
2703
3922
|
if (sessions.length === 0) {
|
|
2704
|
-
process.stderr.write(
|
|
2705
|
-
"Not connected. In PowerPoint open slidemind → Settings → General → Connect CLI, press the link button, and run the command it shows.\n",
|
|
2706
|
-
);
|
|
3923
|
+
process.stderr.write(`${NOT_CONNECTED_LINE}\n`);
|
|
2707
3924
|
return;
|
|
2708
3925
|
}
|
|
2709
3926
|
const { httpJson } = await loadBridgeModule("http-json");
|
|
3927
|
+
const graph = await loadBridgeModule("graph-auth");
|
|
2710
3928
|
const def = cfg.getDefaultBackend();
|
|
2711
3929
|
const lines = await Promise.all(
|
|
2712
3930
|
sessions.map(async (s) => {
|
|
@@ -2725,7 +3943,12 @@ async function cmdWhoami() {
|
|
|
2725
3943
|
} catch {
|
|
2726
3944
|
detail = "unreachable — check your connection or authorize again";
|
|
2727
3945
|
}
|
|
2728
|
-
|
|
3946
|
+
// Storage-only — no probe. The Graph sign-in is a SECOND identity, so it
|
|
3947
|
+
// is reported next to the session rather than folded into it.
|
|
3948
|
+
const graphLine = graph.hasGraphSession(s.backendURL)
|
|
3949
|
+
? "Microsoft 365: signed in"
|
|
3950
|
+
: "Microsoft 365: not signed in — needed only for window-free SharePoint downloads (`slidemind login`)";
|
|
3951
|
+
return `${marker} ${s.label}\n ${detail}\n ${graphLine}`;
|
|
2729
3952
|
}),
|
|
2730
3953
|
);
|
|
2731
3954
|
process.stdout.write(
|
|
@@ -2765,6 +3988,10 @@ async function cmdLogout(rest) {
|
|
|
2765
3988
|
}
|
|
2766
3989
|
|
|
2767
3990
|
const { httpJson } = await loadBridgeModule("http-json");
|
|
3991
|
+
// Unpairing takes the WHOLE record, the Microsoft 365 refresh token included —
|
|
3992
|
+
// it was stored for this environment's session and must not outlive it. Said
|
|
3993
|
+
// out loud because signing back in is a second command, not part of `setup`.
|
|
3994
|
+
const droppedGraph = toRemove.filter((s) => s.msGraphRefreshToken);
|
|
2768
3995
|
for (const s of toRemove) {
|
|
2769
3996
|
try {
|
|
2770
3997
|
await httpJson("POST", `${s.backendURL}/api/cli/logout`, {
|
|
@@ -2773,12 +4000,21 @@ async function cmdLogout(rest) {
|
|
|
2773
4000
|
} catch {
|
|
2774
4001
|
/* best-effort server-side revoke; we still drop the local session */
|
|
2775
4002
|
}
|
|
4003
|
+
// Through the token setter, not just the record removal: the refresh token
|
|
4004
|
+
// may live in the macOS keychain (config.mjs), which removing the session
|
|
4005
|
+
// record alone would orphan.
|
|
4006
|
+
cfg.setGraphRefreshToken(s.backendURL, null);
|
|
2776
4007
|
cfg.removeSession(s.backendURL);
|
|
2777
4008
|
}
|
|
2778
4009
|
const remaining = cfg.readSessions().length;
|
|
2779
4010
|
process.stdout.write(
|
|
2780
4011
|
`Logged out of ${toRemove.map((s) => s.label).join(", ")}.` +
|
|
2781
|
-
(remaining > 0 ? ` ${remaining} environment(s) still connected.\n` : "\n")
|
|
4012
|
+
(remaining > 0 ? ` ${remaining} environment(s) still connected.\n` : "\n") +
|
|
4013
|
+
(droppedGraph.length > 0
|
|
4014
|
+
? `The Microsoft 365 sign-in for ${droppedGraph
|
|
4015
|
+
.map((s) => s.label)
|
|
4016
|
+
.join(", ")} was dropped too — run 'slidemind login' after reconnecting.\n`
|
|
4017
|
+
: ""),
|
|
2782
4018
|
);
|
|
2783
4019
|
}
|
|
2784
4020
|
|
|
@@ -2803,12 +4039,114 @@ async function cmdUse(rest) {
|
|
|
2803
4039
|
);
|
|
2804
4040
|
}
|
|
2805
4041
|
|
|
4042
|
+
// `slidemind adopt <file.pptx> [--deck <slug>] [--brand <presFP>] [--no-convert]`
|
|
4043
|
+
// — reconstruct a local deck workspace from a presentation's deck-embedded
|
|
4044
|
+
// source tags (slides authored with slidemind carry their jsx inside the file).
|
|
4045
|
+
// Slides with no source are handed to the add-in's deterministic PPTX→JSX
|
|
4046
|
+
// conversion route where it is available (`docs/slidemind-cli-connect/
|
|
4047
|
+
// 14-foreign-slide-conversion.md`); whatever it declines folds in as native
|
|
4048
|
+
// reused .pptx. See also intern-slidemind docs/deck-embedded-source-spec.md.
|
|
4049
|
+
async function cmdAdopt(rest) {
|
|
4050
|
+
const args = [...rest];
|
|
4051
|
+
let deckSlug = null;
|
|
4052
|
+
let brand = null;
|
|
4053
|
+
let convert = true;
|
|
4054
|
+
const positional = [];
|
|
4055
|
+
for (let i = 0; i < args.length; i++) {
|
|
4056
|
+
const a = args[i];
|
|
4057
|
+
if (a === "--deck") {
|
|
4058
|
+
deckSlug = args[++i] ?? null;
|
|
4059
|
+
if (!deckSlug) fail("--deck expects a deck slug");
|
|
4060
|
+
} else if (a === "--brand") {
|
|
4061
|
+
brand = args[++i] ?? null;
|
|
4062
|
+
if (!brand) fail("--brand expects a design id (presFP)");
|
|
4063
|
+
} else if (a === "--no-convert") {
|
|
4064
|
+
convert = false;
|
|
4065
|
+
} else if (a.startsWith("-")) {
|
|
4066
|
+
fail(`unknown flag: ${a}`);
|
|
4067
|
+
} else {
|
|
4068
|
+
positional.push(a);
|
|
4069
|
+
}
|
|
4070
|
+
}
|
|
4071
|
+
const pptxArg = positional[0];
|
|
4072
|
+
if (!pptxArg) {
|
|
4073
|
+
fail("usage: slidemind adopt <file.pptx> [--deck <slug>] [--brand <presFP>] [--no-convert]");
|
|
4074
|
+
}
|
|
4075
|
+
// v1 is FILE decks only. A presId (or any non-file argument) would mean
|
|
4076
|
+
// converting a LIVE presentation, which needs slide-export and tag-read
|
|
4077
|
+
// events the relay does not have — say so instead of "no such file".
|
|
4078
|
+
const {
|
|
4079
|
+
LIVE_DECK_UNSUPPORTED,
|
|
4080
|
+
adoptDeckFromPptx,
|
|
4081
|
+
convertUntaggedSlides,
|
|
4082
|
+
readSourceDeck,
|
|
4083
|
+
} = await import(pathToFileURL(path.join(SRC_DIR, "lib", "adopt.mjs")).href);
|
|
4084
|
+
if (!LOCAL_PPTX_RE.test(pptxArg)) fail(LIVE_DECK_UNSUPPORTED);
|
|
4085
|
+
const pptxPath = path.resolve(pptxArg);
|
|
4086
|
+
if (!fs.existsSync(pptxPath)) fail(`no such file: ${pptxArg}`);
|
|
4087
|
+
const slug = deckSlug ?? path.basename(pptxPath).replace(/\.pptx$/i, "");
|
|
4088
|
+
// The decks dir, not a hardcoded ./decks — otherwise `adopt` writes where no
|
|
4089
|
+
// other subcommand looks whenever SLIDEMIND_DECKS is set. A RELATIVE slug
|
|
4090
|
+
// must stay inside it (an explicit absolute path is the user's own choice) —
|
|
4091
|
+
// every other untrusted name in this feature is escape-guarded, and this one
|
|
4092
|
+
// rides agent-authored command lines.
|
|
4093
|
+
const decksRoot = path.resolve(resolveDecksDir());
|
|
4094
|
+
const deckDir = path.isAbsolute(slug) ? slug : path.resolve(decksRoot, slug);
|
|
4095
|
+
if (
|
|
4096
|
+
!path.isAbsolute(slug) &&
|
|
4097
|
+
(deckDir === decksRoot || !deckDir.startsWith(decksRoot + path.sep))
|
|
4098
|
+
) {
|
|
4099
|
+
fail(
|
|
4100
|
+
`--deck '${slug}' escapes the decks directory (${decksRoot}) — ` +
|
|
4101
|
+
"pass an absolute path to adopt somewhere else on purpose",
|
|
4102
|
+
);
|
|
4103
|
+
}
|
|
4104
|
+
// ONE read/unzip/tag-decode of the source file, threaded into both halves —
|
|
4105
|
+
// they must see the same snapshot (and a 40 MB deck decodes slowly).
|
|
4106
|
+
const source = readSourceDeck(pptxPath);
|
|
4107
|
+
const converted = convert
|
|
4108
|
+
? await convertUntaggedSlides({
|
|
4109
|
+
pptxPath,
|
|
4110
|
+
deckDir,
|
|
4111
|
+
brandId: brand,
|
|
4112
|
+
note: (line) => process.stderr.write(`${line}\n`),
|
|
4113
|
+
source,
|
|
4114
|
+
})
|
|
4115
|
+
: null;
|
|
4116
|
+
const result = adoptDeckFromPptx({ pptxPath, deckDir, converted, source });
|
|
4117
|
+
// A decks dir outside the cwd (SLIDEMIND_DECKS) relativizes to ../../.. noise
|
|
4118
|
+
// — print the absolute path there instead.
|
|
4119
|
+
const shown = path.relative(process.cwd(), deckDir);
|
|
4120
|
+
process.stdout.write(
|
|
4121
|
+
`Adopted ${result.adopted.length} slide(s) with embedded source` +
|
|
4122
|
+
(result.converted.length
|
|
4123
|
+
? `, converted ${result.converted.length} foreign slide(s) to editable jsx`
|
|
4124
|
+
: "") +
|
|
4125
|
+
(result.native.length
|
|
4126
|
+
? ` and folded ${result.native.length} untagged slide(s) as native .pptx`
|
|
4127
|
+
: "") +
|
|
4128
|
+
` into ${shown.startsWith("..") ? deckDir : shown}/.\n` +
|
|
4129
|
+
(result.skipped.length
|
|
4130
|
+
? `${result.skipped.length} slide(s) stayed native: ` +
|
|
4131
|
+
`${result.skipped
|
|
4132
|
+
.map((s) => `#${s.index ?? "?"} ${s.reason ?? "unknown"}`)
|
|
4133
|
+
.join(", ")}\n`
|
|
4134
|
+
: "") +
|
|
4135
|
+
(result.bundle?.presFP && result.bundle.presFP !== "unknown"
|
|
4136
|
+
? `Deck bound to bundle ${result.bundle.presFP}.\n`
|
|
4137
|
+
: `No bundle reference found in the file — run 'slidemind download-bundle ${slug}' before rendering.\n`) +
|
|
4138
|
+
`Edit slides/*.jsx and run 'slidemind push ${slug}' or 'slidemind build-pptx ${slug}'.\n`,
|
|
4139
|
+
);
|
|
4140
|
+
}
|
|
4141
|
+
|
|
2806
4142
|
async function cmdPush(rest) {
|
|
2807
4143
|
const { deck, passthrough } = splitDeckArg(rest, "push");
|
|
2808
4144
|
let to = null;
|
|
2809
4145
|
let at = null;
|
|
2810
4146
|
let replace = false;
|
|
2811
4147
|
let slides = null;
|
|
4148
|
+
let acceptConversions = false;
|
|
4149
|
+
let force = false;
|
|
2812
4150
|
for (let i = 0; i < passthrough.length; i++) {
|
|
2813
4151
|
const a = passthrough[i];
|
|
2814
4152
|
if (a === "--to") {
|
|
@@ -2823,6 +4161,10 @@ async function cmdPush(rest) {
|
|
|
2823
4161
|
at = n;
|
|
2824
4162
|
} else if (a === "--replace") {
|
|
2825
4163
|
replace = true;
|
|
4164
|
+
} else if (a === "--accept-conversions") {
|
|
4165
|
+
acceptConversions = true;
|
|
4166
|
+
} else if (a === "--force") {
|
|
4167
|
+
force = true;
|
|
2826
4168
|
} else if (a === "--slides") {
|
|
2827
4169
|
const next = passthrough[++i];
|
|
2828
4170
|
if (!next) fail("--slides expects a comma-separated list of slide ids");
|
|
@@ -2851,6 +4193,8 @@ async function cmdPush(rest) {
|
|
|
2851
4193
|
insertAt: at,
|
|
2852
4194
|
replace,
|
|
2853
4195
|
slideIds: slides,
|
|
4196
|
+
acceptConversions,
|
|
4197
|
+
force,
|
|
2854
4198
|
onProgress: renderProgressPrinter(),
|
|
2855
4199
|
});
|
|
2856
4200
|
for (const w of result.warnings ?? []) {
|
|
@@ -3241,7 +4585,35 @@ function extractBackendScope(argv) {
|
|
|
3241
4585
|
return out;
|
|
3242
4586
|
}
|
|
3243
4587
|
|
|
4588
|
+
// Route every printed byte through the credential scrubber, once, before any
|
|
4589
|
+
// command runs. The alternative is scrubbing at ~80 individual write sites
|
|
4590
|
+
// across `bin/` and `src/` and remembering to do it at the next one — this way
|
|
4591
|
+
// a secret cannot reach a stream no matter who writes it or how it got into the
|
|
4592
|
+
// string (a response body used as an error detail, a `--json` payload spread
|
|
4593
|
+
// from a server response, an argv echo). Only strings are touched: the CLI
|
|
4594
|
+
// writes binary to files, never to a stream, and a non-string chunk passes
|
|
4595
|
+
// through untouched regardless.
|
|
4596
|
+
function scrubOutputStreams() {
|
|
4597
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
4598
|
+
const write = stream.write.bind(stream);
|
|
4599
|
+
stream.write = (chunk, ...rest) =>
|
|
4600
|
+
write(typeof chunk === "string" ? scrub(chunk) : chunk, ...rest);
|
|
4601
|
+
}
|
|
4602
|
+
}
|
|
4603
|
+
|
|
3244
4604
|
async function main(rawArgv) {
|
|
4605
|
+
scrubOutputStreams();
|
|
4606
|
+
// A claim pasted WITHOUT the `setup` verb never reaches `decodeSetupClaim`,
|
|
4607
|
+
// so nothing would register it — and `unknown command: <argv>` would print it
|
|
4608
|
+
// whole. Registering straight off argv closes that without the error message
|
|
4609
|
+
// needing to know what a claim is.
|
|
4610
|
+
for (const a of rawArgv) {
|
|
4611
|
+
// The claim, not the whole argument: a user who pastes the entire
|
|
4612
|
+
// `npx … setup smc1_…` line as one word must still have the credential
|
|
4613
|
+
// inside it scrubbed, not just that exact line.
|
|
4614
|
+
const claim = typeof a === "string" ? a.match(/smc1_[A-Za-z0-9_-]+/) : null;
|
|
4615
|
+
if (claim) registerSecret(claim[0]);
|
|
4616
|
+
}
|
|
3245
4617
|
const argv = extractBackendScope(rawArgv);
|
|
3246
4618
|
const [cmd, ...rest] = argv;
|
|
3247
4619
|
if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
@@ -3268,6 +4640,9 @@ async function main(rawArgv) {
|
|
|
3268
4640
|
case "build-pptx":
|
|
3269
4641
|
await cmdBuildPptx(rest);
|
|
3270
4642
|
return 0;
|
|
4643
|
+
case "inspect-template-deck":
|
|
4644
|
+
await cmdInspectTemplateDeck(rest);
|
|
4645
|
+
return 0;
|
|
3271
4646
|
case "view-draft":
|
|
3272
4647
|
await cmdViewDraft(rest);
|
|
3273
4648
|
return 0;
|
|
@@ -3280,6 +4655,9 @@ async function main(rawArgv) {
|
|
|
3280
4655
|
case "view-slide":
|
|
3281
4656
|
await cmdViewSlide(rest);
|
|
3282
4657
|
return 0;
|
|
4658
|
+
case "read-source":
|
|
4659
|
+
await cmdReadSource(rest);
|
|
4660
|
+
return 0;
|
|
3283
4661
|
case "insert-slide":
|
|
3284
4662
|
await cmdInsertSlide(rest);
|
|
3285
4663
|
return 0;
|
|
@@ -3304,6 +4682,9 @@ async function main(rawArgv) {
|
|
|
3304
4682
|
case "new":
|
|
3305
4683
|
await cmdNew(rest);
|
|
3306
4684
|
return 0;
|
|
4685
|
+
case "login":
|
|
4686
|
+
await cmdLogin(rest);
|
|
4687
|
+
break;
|
|
3307
4688
|
case "whoami":
|
|
3308
4689
|
await cmdWhoami(rest);
|
|
3309
4690
|
break;
|
|
@@ -3324,6 +4705,9 @@ async function main(rawArgv) {
|
|
|
3324
4705
|
case "read":
|
|
3325
4706
|
await cmdRead(rest);
|
|
3326
4707
|
return 0;
|
|
4708
|
+
case "adopt":
|
|
4709
|
+
await cmdAdopt(rest);
|
|
4710
|
+
return 0;
|
|
3327
4711
|
case "push":
|
|
3328
4712
|
await cmdPush(rest);
|
|
3329
4713
|
return 0;
|