slidemind 0.2.4 → 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 +37 -8
- package/README.md +32 -2
- package/bin/slidemind.mjs +535 -122
- package/install.ps1 +70 -0
- package/install.sh +65 -0
- package/package.json +3 -1
- package/skills/create-slides/SKILL.md +30 -6
- package/skills/search/SKILL.md +3 -1
- package/src/bridge/build-and-push.mjs +418 -38
- package/src/bridge/capture.mjs +12 -1
- package/src/bridge/client.mjs +344 -26
- package/src/bridge/config.mjs +193 -1
- 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/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 +69 -1
- 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,6 +70,7 @@ 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` +
|
|
@@ -94,6 +102,7 @@ function printHelp() {
|
|
|
94
102
|
` --pack <dir> explicit template-pack directory (plugin integrations)\n` +
|
|
95
103
|
` --to <presId> target a specific window (required if >1 open)\n` +
|
|
96
104
|
` --out <path> output path (default <deck>/<deck>.pptx)\n` +
|
|
105
|
+
` --force discard manual PowerPoint edits instead of reconciling them\n` +
|
|
97
106
|
` slidemind inspect-template-deck <deck> Inspect a template deck as JSON for pack-owned validation\n` +
|
|
98
107
|
` --pack <dir> explicit template-pack directory (required)\n` +
|
|
99
108
|
` --allow-unrendered-freeform include JSX inserts without .pptx artifacts\n` +
|
|
@@ -146,6 +155,11 @@ function printHelp() {
|
|
|
146
155
|
` --to <presId> the window the search ran on (relayed path only; ignored with --from)\n` +
|
|
147
156
|
` --at <N> numeric prefix to start at (default: append)\n` +
|
|
148
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` +
|
|
149
163
|
` slidemind download-result [opts] Download image/icon search result(s) into a deck's assets/\n` +
|
|
150
164
|
` --search <id> searchId from 'slidemind search'\n` +
|
|
151
165
|
` --pick <n,..> 1-based selection number(s)\n` +
|
|
@@ -177,6 +191,11 @@ function printHelp() {
|
|
|
177
191
|
` --at <index> insert after slide N (0-based); default = append\n` +
|
|
178
192
|
` --replace delete existing slides first (destructive)\n` +
|
|
179
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` +
|
|
180
199
|
` slidemind whoami Show every connected environment (* = default)\n` +
|
|
181
200
|
` slidemind use <url|env> Set the default environment\n` +
|
|
182
201
|
` slidemind logout [url|env] Unpair one environment (--all to unpair every one)\n` +
|
|
@@ -522,9 +541,12 @@ async function cmdBuildPptx(rest) {
|
|
|
522
541
|
let to = null;
|
|
523
542
|
let outPath = null;
|
|
524
543
|
let packDir = null;
|
|
544
|
+
let force = false;
|
|
525
545
|
for (let i = 0; i < passthrough.length; i++) {
|
|
526
546
|
const a = passthrough[i];
|
|
527
|
-
if (a === "--
|
|
547
|
+
if (a === "--force") {
|
|
548
|
+
force = true;
|
|
549
|
+
} else if (a === "--to") {
|
|
528
550
|
to = passthrough[++i];
|
|
529
551
|
if (!to) fail("--to expects a presId");
|
|
530
552
|
} else if (a === "--pack") {
|
|
@@ -616,6 +638,7 @@ async function cmdBuildPptx(rest) {
|
|
|
616
638
|
deckDir,
|
|
617
639
|
presentationId: to,
|
|
618
640
|
outPath,
|
|
641
|
+
force,
|
|
619
642
|
onProgress: renderProgressPrinter(),
|
|
620
643
|
});
|
|
621
644
|
process.stdout.write(
|
|
@@ -841,7 +864,7 @@ async function resolveEngineRouting(client, { to = null, brandId = null, pick =
|
|
|
841
864
|
origin = await client.resolveSearchBackend(searchId);
|
|
842
865
|
} catch (err) {
|
|
843
866
|
// The probe distinguishes "no environment holds this record" from the
|
|
844
|
-
// failures that only LOOK like it — a dead session, a
|
|
867
|
+
// failures that only LOOK like it — a dead session, a missing sign-in —
|
|
845
868
|
// and hands the latter back for their own remediation.
|
|
846
869
|
await failOnEngineBlocker(client, err, { to });
|
|
847
870
|
}
|
|
@@ -882,7 +905,7 @@ async function deckBrandId(deckDir) {
|
|
|
882
905
|
}
|
|
883
906
|
|
|
884
907
|
// A pane-ready window, or null — never a throw, and never a wait. Used by the
|
|
885
|
-
//
|
|
908
|
+
// login_required paths below, where an open pane is an OPTION rather than a
|
|
886
909
|
// precondition. `to` is honoured only when it names a window that is actually
|
|
887
910
|
// registered; a stale --to must not cost the caller its fallback.
|
|
888
911
|
async function openPaneEntry(client, to = null) {
|
|
@@ -897,19 +920,20 @@ async function openPaneEntry(client, to = null) {
|
|
|
897
920
|
}
|
|
898
921
|
}
|
|
899
922
|
|
|
900
|
-
// The ONE rendering of a
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
|
|
908
|
-
|
|
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);
|
|
909
933
|
if (!blocker) return null;
|
|
910
934
|
const pane = await openPaneEntry(client, to);
|
|
911
935
|
if (!pane) return blocker.message;
|
|
912
|
-
const env = pane.entry.env
|
|
936
|
+
const env = envTag(pane.entry.env);
|
|
913
937
|
return (
|
|
914
938
|
`${blocker.message} A slidemind pane is already open${env} and signs each request live, so a fresh ` +
|
|
915
939
|
`search through that window works now: slidemind search "<query>" --to ${pane.entry.presId}.`
|
|
@@ -917,24 +941,23 @@ async function staleSnapshotMessage(client, err, { to = null } = {}) {
|
|
|
917
941
|
}
|
|
918
942
|
|
|
919
943
|
// Engine-route failures the USER has to clear get one actionable line instead of
|
|
920
|
-
// a relay error. `
|
|
921
|
-
//
|
|
922
|
-
// add-in once…"), and only corpus search degrades; render and bundle don't.
|
|
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.
|
|
923
946
|
// Anything else is re-thrown for the top-level handler.
|
|
924
947
|
async function failOnEngineBlocker(client, err, { to = null } = {}) {
|
|
925
|
-
const message = await
|
|
948
|
+
const message = await loginRequiredMessage(client, err, { to });
|
|
926
949
|
if (message) fail(message);
|
|
927
950
|
throw err;
|
|
928
951
|
}
|
|
929
952
|
|
|
930
|
-
// A
|
|
931
|
-
// that can be replayed pane-side qualifies — creating a NEW search,
|
|
932
|
-
// pane mints itself. A record-bound op on an opaque searchId
|
|
933
|
-
// handler only understands its own per-window numeric ids,
|
|
934
|
-
//
|
|
935
|
-
// or null.
|
|
936
|
-
async function
|
|
937
|
-
if (!client.
|
|
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;
|
|
938
961
|
return openPaneEntry(client, to);
|
|
939
962
|
}
|
|
940
963
|
|
|
@@ -955,6 +978,48 @@ function describeHostOnlyEngineFailure(err) {
|
|
|
955
978
|
);
|
|
956
979
|
}
|
|
957
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
|
+
|
|
958
1023
|
async function cmdSearch(rest) {
|
|
959
1024
|
let asJson = false;
|
|
960
1025
|
let to = null;
|
|
@@ -1032,7 +1097,7 @@ async function cmdSearch(rest) {
|
|
|
1032
1097
|
});
|
|
1033
1098
|
const presId = routing.presId;
|
|
1034
1099
|
const backendURL = routing.backendURL;
|
|
1035
|
-
// Mutable, because a
|
|
1100
|
+
// Mutable, because a login_required refusal can push a direct search onto
|
|
1036
1101
|
// the pane relay below — and once it does, the searchId belongs to a WINDOW
|
|
1037
1102
|
// and every next-step hint has to name it.
|
|
1038
1103
|
let direct = routing.direct;
|
|
@@ -1046,7 +1111,7 @@ async function cmdSearch(rest) {
|
|
|
1046
1111
|
// - an explicit slide intent (--intent content|visual) targets slides.
|
|
1047
1112
|
// This is belt-and-suspenders for an add-in regression where a missing
|
|
1048
1113
|
// subtype no longer defaulted to "image"; when the subtype is unknown we omit
|
|
1049
|
-
// it and let the add-in resolve
|
|
1114
|
+
// it and let the add-in resolve the caller's groups itself.
|
|
1050
1115
|
let subtype = slideIntent ? "slide" : null;
|
|
1051
1116
|
if (!engineId) {
|
|
1052
1117
|
if (slideIntent) {
|
|
@@ -1138,16 +1203,15 @@ async function cmdSearch(rest) {
|
|
|
1138
1203
|
})
|
|
1139
1204
|
: await relaySearch(routing.registry);
|
|
1140
1205
|
} catch (err) {
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
|
|
1146
|
-
const relay = direct ? await paneRelayForStaleSnapshot(client, err, { to }) : null;
|
|
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;
|
|
1147
1211
|
if (!relay) await failOnEngineBlocker(client, err, { to });
|
|
1148
1212
|
process.stderr.write(
|
|
1149
|
-
`note: this
|
|
1150
|
-
`slidemind pane${relay.entry.env
|
|
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`,
|
|
1151
1215
|
);
|
|
1152
1216
|
direct = false;
|
|
1153
1217
|
registry = relay.registry;
|
|
@@ -1241,20 +1305,20 @@ async function cmdSearch(rest) {
|
|
|
1241
1305
|
// add-ins that don't echo a top-level subtype.
|
|
1242
1306
|
const isSlideResults =
|
|
1243
1307
|
res.subtype === "slide" || results.some((r) => r.kind === "slide");
|
|
1244
|
-
const
|
|
1308
|
+
const envLabel = envTag(ranEntry?.env);
|
|
1245
1309
|
if (direct) {
|
|
1246
1310
|
// The searchId belongs to the BACKEND here, not to a window: picks work with
|
|
1247
1311
|
// PowerPoint closed, so the follow-up commands carry no --to. Live insert is
|
|
1248
1312
|
// the one step that still names a window (it types into an open deck).
|
|
1249
1313
|
if (isSlideResults) {
|
|
1250
1314
|
process.stdout.write(
|
|
1251
|
-
`searchId ${res.searchId}${
|
|
1315
|
+
`searchId ${res.searchId}${envLabel} is held by the add-in — reuse it with no PowerPoint window:\n` +
|
|
1252
1316
|
` slidemind add-slide --search ${res.searchId} --pick <n> --deck <deck>\n` +
|
|
1253
1317
|
` slidemind insert-slide --search ${res.searchId} --pick <n> --to <presId> (live insert needs an open window)\n`,
|
|
1254
1318
|
);
|
|
1255
1319
|
} else {
|
|
1256
1320
|
process.stdout.write(
|
|
1257
|
-
`searchId ${res.searchId}${
|
|
1321
|
+
`searchId ${res.searchId}${envLabel} is held by the add-in — download image result(s) into a deck (no window needed):\n` +
|
|
1258
1322
|
` slidemind download-result --search ${res.searchId} --pick <n[,n,...]> --deck <deck> [--icon-color <hex>]\n`,
|
|
1259
1323
|
);
|
|
1260
1324
|
}
|
|
@@ -1262,7 +1326,7 @@ async function cmdSearch(rest) {
|
|
|
1262
1326
|
// The searchId is bound to the window the search ran on — reuse must target
|
|
1263
1327
|
// that same window, so spell out its presId in the next-step commands.
|
|
1264
1328
|
process.stdout.write(
|
|
1265
|
-
`searchId ${res.searchId} ran on window ${ranPresId}${
|
|
1329
|
+
`searchId ${res.searchId} ran on window ${ranPresId}${envLabel} — reuse from THIS window:\n` +
|
|
1266
1330
|
` slidemind insert-slide --search ${res.searchId} --pick <n> --to ${ranPresId}\n` +
|
|
1267
1331
|
` slidemind add-slide --search ${res.searchId} --pick <n> --deck <deck> --to ${ranPresId}\n`,
|
|
1268
1332
|
);
|
|
@@ -1270,7 +1334,7 @@ async function cmdSearch(rest) {
|
|
|
1270
1334
|
// The searchId is bound to the window the search ran on — download must
|
|
1271
1335
|
// target that same window, so spell out its presId in the next-step command.
|
|
1272
1336
|
process.stdout.write(
|
|
1273
|
-
`searchId ${res.searchId} ran on window ${ranPresId}${
|
|
1337
|
+
`searchId ${res.searchId} ran on window ${ranPresId}${envLabel} — download image result(s) into a deck:\n` +
|
|
1274
1338
|
` slidemind download-result --search ${res.searchId} --pick <n[,n,...]> --deck <deck> --to ${ranPresId} [--icon-color <hex>]\n`,
|
|
1275
1339
|
);
|
|
1276
1340
|
}
|
|
@@ -1390,7 +1454,8 @@ async function cmdLs(rest) {
|
|
|
1390
1454
|
// environments can carry identically-named engines, and without the tag the
|
|
1391
1455
|
// pooled list gives no way to tell them apart. Deduped scope rows carry no env
|
|
1392
1456
|
// of their own (they exist on every environment), so they stay untagged.
|
|
1393
|
-
const multiEnv =
|
|
1457
|
+
const multiEnv =
|
|
1458
|
+
isMultiEnv() || envs.length > 1 || new Set(engines.map((e) => e.env)).size > 1;
|
|
1394
1459
|
for (const e of engines) {
|
|
1395
1460
|
const kind = [e.type, e.subtype].filter(Boolean).join("/");
|
|
1396
1461
|
const env = multiEnv && e.env ? `[${e.env}] ` : "";
|
|
@@ -1448,8 +1513,8 @@ function parsePicks(raw, flag = "--pick") {
|
|
|
1448
1513
|
* (a pane reconnect churns the presId), so a pane error that reaches here is one
|
|
1449
1514
|
* only the user can clear — and the way around it is the window-free fold.
|
|
1450
1515
|
*
|
|
1451
|
-
* A
|
|
1452
|
-
*
|
|
1516
|
+
* A login_required refusal is NOT among them: it is authored once, in
|
|
1517
|
+
* loginRequiredMessage, which the caller consults first.
|
|
1453
1518
|
*/
|
|
1454
1519
|
function describeInsertSlideFailure(err, searchId) {
|
|
1455
1520
|
const code = err?.code ?? err?.body?.code ?? null;
|
|
@@ -1570,7 +1635,7 @@ async function cmdInsertSlide(rest) {
|
|
|
1570
1635
|
});
|
|
1571
1636
|
} catch (err) {
|
|
1572
1637
|
fail(
|
|
1573
|
-
(await
|
|
1638
|
+
(await loginRequiredMessage(client, err, { to: entry.presId })) ??
|
|
1574
1639
|
describeInsertSlideFailure(err, searchId),
|
|
1575
1640
|
);
|
|
1576
1641
|
}
|
|
@@ -1778,7 +1843,7 @@ async function cmdAddSlide(rest) {
|
|
|
1778
1843
|
continue;
|
|
1779
1844
|
} catch (err) {
|
|
1780
1845
|
fs.rmSync(tempPath, { force: true });
|
|
1781
|
-
const stale = await
|
|
1846
|
+
const stale = await loginRequiredMessage(client, err, { to });
|
|
1782
1847
|
if (stale) {
|
|
1783
1848
|
cleanupTemps();
|
|
1784
1849
|
fail(stale);
|
|
@@ -1789,8 +1854,11 @@ async function cmdAddSlide(rest) {
|
|
|
1789
1854
|
const routeMissing =
|
|
1790
1855
|
(err?.status === 404 && !err?.code) || err?.status === 501;
|
|
1791
1856
|
if (!routeMissing || !presId) {
|
|
1857
|
+
const described = await describeEngineByteFailure(client, err, {
|
|
1858
|
+
backendURL,
|
|
1859
|
+
});
|
|
1792
1860
|
cleanupTemps();
|
|
1793
|
-
fail(`add-slide failed for selection ${pick}: ${err.message}`);
|
|
1861
|
+
fail(`add-slide failed for selection ${pick}: ${described ?? err.message}`);
|
|
1794
1862
|
}
|
|
1795
1863
|
}
|
|
1796
1864
|
}
|
|
@@ -1997,10 +2065,10 @@ async function cmdReadSource(rest) {
|
|
|
1997
2065
|
backendURL: routing.backendURL,
|
|
1998
2066
|
});
|
|
1999
2067
|
} catch (err) {
|
|
2000
|
-
// The
|
|
2068
|
+
// The login_required line is authored once and knows about an open pane;
|
|
2001
2069
|
// everything else is this command's own vocabulary.
|
|
2002
2070
|
fail(
|
|
2003
|
-
(await
|
|
2071
|
+
(await loginRequiredMessage(client, err, { to })) ??
|
|
2004
2072
|
describeReadSourceFailure(err, searchId, pick),
|
|
2005
2073
|
);
|
|
2006
2074
|
}
|
|
@@ -2033,8 +2101,8 @@ async function cmdReadSource(rest) {
|
|
|
2033
2101
|
|
|
2034
2102
|
/**
|
|
2035
2103
|
* Turn a source-presentation failure into the action that actually fixes it.
|
|
2036
|
-
* A
|
|
2037
|
-
*
|
|
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
|
|
2038
2106
|
* across every command.
|
|
2039
2107
|
*/
|
|
2040
2108
|
function describeReadSourceFailure(err, searchId, pick) {
|
|
@@ -2129,19 +2197,23 @@ async function cmdDownloadResult(rest) {
|
|
|
2129
2197
|
// One user-clearable blocker sinks the whole batch identically — every pick
|
|
2130
2198
|
// would fail the same way, and the fix is one action, not N error lines.
|
|
2131
2199
|
const stale = settled.find(
|
|
2132
|
-
(r) => r.status === "rejected" && client.
|
|
2200
|
+
(r) => r.status === "rejected" && client.loginRequiredBlocker?.(r.reason),
|
|
2133
2201
|
);
|
|
2134
|
-
if (stale) fail(await
|
|
2135
|
-
const results =
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
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
|
+
),
|
|
2145
2217
|
);
|
|
2146
2218
|
const { formatSize } = await import(
|
|
2147
2219
|
pathToFileURL(path.join(SRC_DIR, "bridge", "image-dimensions.mjs")).href
|
|
@@ -2227,7 +2299,9 @@ async function cmdConceptImages(rest) {
|
|
|
2227
2299
|
pathToFileURL(path.join(BUILDERS_DIR, "plan.mjs")).href
|
|
2228
2300
|
);
|
|
2229
2301
|
const planState = plan.loadPlanState(deckDir);
|
|
2230
|
-
|
|
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") {
|
|
2231
2305
|
process.stderr.write(
|
|
2232
2306
|
`slidemind: note — ${deck}'s plan is not signed off yet ` +
|
|
2233
2307
|
`(run \`slidemind plan ${deck}\`); generating concept mockups from the current plan.md anyway.\n`,
|
|
@@ -2448,7 +2522,7 @@ async function cmdPresentations(rest) {
|
|
|
2448
2522
|
}
|
|
2449
2523
|
for (const p of sorted) {
|
|
2450
2524
|
const label = p.title || p.fileName || "(untitled)";
|
|
2451
|
-
const env = p.env ? `[${p.env}]\t` : "";
|
|
2525
|
+
const env = p.env && isMultiEnv() ? `[${p.env}]\t` : "";
|
|
2452
2526
|
const state =
|
|
2453
2527
|
p.connectionState ||
|
|
2454
2528
|
(p.connected === false || p.suspendedAt ? "suspended" : "connected");
|
|
@@ -2594,33 +2668,6 @@ function connectionNextAction(pub) {
|
|
|
2594
2668
|
return connectionBlocker(pub)?.message ?? "Ready.";
|
|
2595
2669
|
}
|
|
2596
2670
|
|
|
2597
|
-
// The permission SNAPSHOT behind corpus search ages out when nobody opens the
|
|
2598
|
-
// add-in for a while. It is a DEGRADATION, never a blocker: render, bundle and
|
|
2599
|
-
// every already-materialized asset keep working, only corpus search stops — so
|
|
2600
|
-
// it must not sit on either ladder. Newer add-ins report it on the readiness
|
|
2601
|
-
// capabilities; the message is authored once, in the client, next to the live
|
|
2602
|
-
// 403 shape the engine routes return (client.permissionsStaleBlocker).
|
|
2603
|
-
// Which connected environments report an aged-out snapshot on
|
|
2604
|
-
// `/api/cli/readiness`. Newer add-ins emit the flag; older ones never do, and
|
|
2605
|
-
// there the condition only surfaces as a live 403 on a search.
|
|
2606
|
-
function permissionsStaleEnvs(pub) {
|
|
2607
|
-
return (pub.backends ?? [])
|
|
2608
|
-
.filter((b) => b.ok && b.capabilities?.permissionsStale === true)
|
|
2609
|
-
.map((b) => b.env);
|
|
2610
|
-
}
|
|
2611
|
-
|
|
2612
|
-
function permissionsStaleDegradation(pub, client) {
|
|
2613
|
-
const stale = (pub.backends ?? []).find(
|
|
2614
|
-
(b) => b.ok && b.capabilities?.permissionsStale === true,
|
|
2615
|
-
);
|
|
2616
|
-
if (!stale) return null;
|
|
2617
|
-
const action = stale.capabilities?.permissionsStaleAction;
|
|
2618
|
-
return client.permissionsStaleBlocker({
|
|
2619
|
-
code: "permissions_stale",
|
|
2620
|
-
body: typeof action === "string" ? { action } : {},
|
|
2621
|
-
});
|
|
2622
|
-
}
|
|
2623
|
-
|
|
2624
2671
|
// ---------- one-command setup (npx bootstrap) ----------
|
|
2625
2672
|
//
|
|
2626
2673
|
// `npx --yes slidemind@latest setup <claim>` is the whole onboarding: the user
|
|
@@ -2642,6 +2689,7 @@ async function cmdSetup(rest) {
|
|
|
2642
2689
|
let target = "auto";
|
|
2643
2690
|
let doInstall = true;
|
|
2644
2691
|
let doReady = true;
|
|
2692
|
+
let doLogin = true;
|
|
2645
2693
|
let asJson = false;
|
|
2646
2694
|
for (let i = 0; i < rest.length; i++) {
|
|
2647
2695
|
const a = rest[i];
|
|
@@ -2655,6 +2703,8 @@ async function cmdSetup(rest) {
|
|
|
2655
2703
|
doInstall = false;
|
|
2656
2704
|
} else if (a === "--no-ready") {
|
|
2657
2705
|
doReady = false;
|
|
2706
|
+
} else if (a === "--no-login") {
|
|
2707
|
+
doLogin = false;
|
|
2658
2708
|
} else if (a === "--json") {
|
|
2659
2709
|
asJson = true;
|
|
2660
2710
|
} else if (a.startsWith("-")) {
|
|
@@ -2689,14 +2739,24 @@ async function cmdSetup(rest) {
|
|
|
2689
2739
|
ui.banner(pkg.version, "setup");
|
|
2690
2740
|
|
|
2691
2741
|
// --- 1. spend the claim -------------------------------------------------
|
|
2692
|
-
|
|
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 : "");
|
|
2693
2749
|
const sessionToken = await claims.redeemSetupClaim({
|
|
2694
2750
|
backendURL,
|
|
2695
2751
|
secret,
|
|
2696
2752
|
cliVersion: pkg.version,
|
|
2697
2753
|
});
|
|
2698
2754
|
cfg.upsertSession({ backendURL, sessionToken, label: envLabel });
|
|
2699
|
-
ui.step({
|
|
2755
|
+
ui.step({
|
|
2756
|
+
label: "authorize",
|
|
2757
|
+
value: showEnv ? envLabel : "connected",
|
|
2758
|
+
note: "session saved",
|
|
2759
|
+
});
|
|
2700
2760
|
|
|
2701
2761
|
// --- 2. make this CLI permanent ----------------------------------------
|
|
2702
2762
|
// Without this the whole thing evaporates with npx's cache: the user would be
|
|
@@ -2780,6 +2840,52 @@ async function cmdSetup(rest) {
|
|
|
2780
2840
|
bundleMod.clearBundleCache();
|
|
2781
2841
|
}
|
|
2782
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
|
+
|
|
2783
2889
|
if (asJson) {
|
|
2784
2890
|
const globalInstall =
|
|
2785
2891
|
doInstall && !pluginInstall
|
|
@@ -2812,8 +2918,8 @@ async function cmdSetup(rest) {
|
|
|
2812
2918
|
|
|
2813
2919
|
ui.done(
|
|
2814
2920
|
target === "none" || (skills.length === 0 && skippedPlugins.length === 0)
|
|
2815
|
-
? `connected to ${envLabel} — run 'slidemind ready' to start`
|
|
2816
|
-
: `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`,
|
|
2817
2923
|
);
|
|
2818
2924
|
|
|
2819
2925
|
if (installError) {
|
|
@@ -2831,7 +2937,7 @@ async function cmdSetup(rest) {
|
|
|
2831
2937
|
return;
|
|
2832
2938
|
}
|
|
2833
2939
|
|
|
2834
|
-
// ---
|
|
2940
|
+
// --- 5. finish the session the user actually came for -------------------
|
|
2835
2941
|
// `ready` exits non-zero for every legitimate not-yet blocker (PowerPoint
|
|
2836
2942
|
// closed, no design activated). Setup already succeeded at that point, so its
|
|
2837
2943
|
// blocker is information, not a failure — run it for the message and the
|
|
@@ -2987,6 +3093,54 @@ async function cmdReady(rest) {
|
|
|
2987
3093
|
return engineInfo;
|
|
2988
3094
|
};
|
|
2989
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
|
+
|
|
2990
3144
|
// --- A design resolved WITHOUT a window — the deck's own binding, an
|
|
2991
3145
|
// explicit --brand, or the backend's brand list. Same payload either way:
|
|
2992
3146
|
// ready, `windowless: true`, and a pane status that is INFORMATIONAL. What
|
|
@@ -3009,6 +3163,7 @@ async function cmdReady(rest) {
|
|
|
3009
3163
|
const designName = readDesignName(result.dir);
|
|
3010
3164
|
const env = result.backendURL ? cfg.deriveEnvLabel(result.backendURL) : null;
|
|
3011
3165
|
const eng = await engines();
|
|
3166
|
+
const m = await m365();
|
|
3012
3167
|
const paneReady = registry.some(client.isPaneReadyPresentation);
|
|
3013
3168
|
const noWindow = registry.length === 0;
|
|
3014
3169
|
const nextAction = noWindow
|
|
@@ -3036,6 +3191,7 @@ async function cmdReady(rest) {
|
|
|
3036
3191
|
bundleEtag: result.etag ?? null,
|
|
3037
3192
|
paneSuspended: !paneReady,
|
|
3038
3193
|
engines: eng,
|
|
3194
|
+
...(m ? { m365: { signedIn: m.signedIn, degradation: m.degradation } } : {}),
|
|
3039
3195
|
warnings: result.warnings ?? [],
|
|
3040
3196
|
nextAction,
|
|
3041
3197
|
},
|
|
@@ -3049,7 +3205,7 @@ async function cmdReady(rest) {
|
|
|
3049
3205
|
? `${designName} (${result.presFP})`
|
|
3050
3206
|
: result.presFP;
|
|
3051
3207
|
process.stdout.write(
|
|
3052
|
-
`Ready — design ${designLabel}${env
|
|
3208
|
+
`Ready — design ${designLabel}${envTag(env)}${noWindow ? " (no PowerPoint window)" : ""}\n`,
|
|
3053
3209
|
);
|
|
3054
3210
|
process.stdout.write(` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`);
|
|
3055
3211
|
if (deckDir) process.stdout.write(` deck ${deckSlug} bound\n`);
|
|
@@ -3064,6 +3220,7 @@ async function cmdReady(rest) {
|
|
|
3064
3220
|
? " Note: pane operations (push/insert-slide/read of a window) will wait for a pane click; engine search does not.\n"
|
|
3065
3221
|
: " Note: pane operations (push/search/read) will wait for a pane click.\n",
|
|
3066
3222
|
);
|
|
3223
|
+
if (m) process.stdout.write(` ${m.line}\n`);
|
|
3067
3224
|
for (const w of result.warnings ?? []) {
|
|
3068
3225
|
process.stderr.write(` warning: ${w}\n`);
|
|
3069
3226
|
}
|
|
@@ -3145,6 +3302,7 @@ async function cmdReady(rest) {
|
|
|
3145
3302
|
// below stands.
|
|
3146
3303
|
if (connectOnly && (await engines()).available) {
|
|
3147
3304
|
const eng = await engines();
|
|
3305
|
+
const m = await m365();
|
|
3148
3306
|
if (asJson) {
|
|
3149
3307
|
process.stdout.write(
|
|
3150
3308
|
JSON.stringify(
|
|
@@ -3153,6 +3311,7 @@ async function cmdReady(rest) {
|
|
|
3153
3311
|
connectOnly: true,
|
|
3154
3312
|
windowless: true,
|
|
3155
3313
|
engines: eng,
|
|
3314
|
+
...(m ? { m365: { signedIn: m.signedIn, degradation: m.degradation } } : {}),
|
|
3156
3315
|
nextAction:
|
|
3157
3316
|
"Ready (no PowerPoint window — engine search runs off the connection; only insert-slide needs one).",
|
|
3158
3317
|
backends: (pub.backends ?? []).map((b) => ({ env: b.env, ok: b.ok })),
|
|
@@ -3164,8 +3323,9 @@ async function cmdReady(rest) {
|
|
|
3164
3323
|
} else {
|
|
3165
3324
|
process.stdout.write(
|
|
3166
3325
|
`Connected. ${pub.backendCount} backend(s), 0 window(s) registered — ` +
|
|
3167
|
-
`engine search runs without one
|
|
3326
|
+
`engine search runs without one${envTag(eng.envs.join(", "))}.\n`,
|
|
3168
3327
|
);
|
|
3328
|
+
if (m) process.stdout.write(` ${m.line}\n`);
|
|
3169
3329
|
}
|
|
3170
3330
|
return;
|
|
3171
3331
|
}
|
|
@@ -3198,7 +3358,7 @@ async function cmdReady(rest) {
|
|
|
3198
3358
|
}
|
|
3199
3359
|
} else if (brands.length > 1) {
|
|
3200
3360
|
const rows = brands.map(
|
|
3201
|
-
(b) => ` ${b.presFP}${b.env
|
|
3361
|
+
(b) => ` ${b.presFP}${envTag(b.env)} ${b.name ?? ""}`.trimEnd(),
|
|
3202
3362
|
);
|
|
3203
3363
|
const blocker = {
|
|
3204
3364
|
code: "ambiguous_brand",
|
|
@@ -3247,6 +3407,7 @@ async function cmdReady(rest) {
|
|
|
3247
3407
|
// --- --connect-only: the connection is up with ≥1 render-ready window; the
|
|
3248
3408
|
// search skill needs nothing more (no design resolution, no bundle). ---
|
|
3249
3409
|
if (connectOnly) {
|
|
3410
|
+
const m = await m365();
|
|
3250
3411
|
if (asJson) {
|
|
3251
3412
|
process.stdout.write(
|
|
3252
3413
|
JSON.stringify(
|
|
@@ -3254,6 +3415,7 @@ async function cmdReady(rest) {
|
|
|
3254
3415
|
ready: true,
|
|
3255
3416
|
connectOnly: true,
|
|
3256
3417
|
engines: await engines(),
|
|
3418
|
+
...(m ? { m365: { signedIn: m.signedIn, degradation: m.degradation } } : {}),
|
|
3257
3419
|
nextAction: "Ready.",
|
|
3258
3420
|
backends: (pub.backends ?? []).map((b) => ({ env: b.env, ok: b.ok })),
|
|
3259
3421
|
},
|
|
@@ -3266,6 +3428,7 @@ async function cmdReady(rest) {
|
|
|
3266
3428
|
process.stdout.write(
|
|
3267
3429
|
`Connected. ${pub.backendCount} backend(s), ${windows} window(s) registered.\n`,
|
|
3268
3430
|
);
|
|
3431
|
+
if (m) process.stdout.write(` ${m.line}\n`);
|
|
3269
3432
|
}
|
|
3270
3433
|
return;
|
|
3271
3434
|
}
|
|
@@ -3304,7 +3467,7 @@ async function cmdReady(rest) {
|
|
|
3304
3467
|
};
|
|
3305
3468
|
const rows = windows.map(
|
|
3306
3469
|
(p) =>
|
|
3307
|
-
` ${p.presId}
|
|
3470
|
+
` ${p.presId} ${envTag(p.env)} ${p.title || p.fileName || "(untitled)"}`,
|
|
3308
3471
|
);
|
|
3309
3472
|
finishBlocker({
|
|
3310
3473
|
blocker,
|
|
@@ -3325,7 +3488,7 @@ async function cmdReady(rest) {
|
|
|
3325
3488
|
"Multiple corporate designs are open — ask the user which to build against, then re-run: slidemind ready --to <presId>.",
|
|
3326
3489
|
};
|
|
3327
3490
|
const rows = cand.map((c) => {
|
|
3328
|
-
const env = c.env ? `
|
|
3491
|
+
const env = envTag(c.env) ? ` ${envTag(c.env)}` : "";
|
|
3329
3492
|
const label = c.title || c.fileName || "(untitled)";
|
|
3330
3493
|
return ` ${c.presId}${env} ${label} (design ${c.brandId ?? "?"})`;
|
|
3331
3494
|
});
|
|
@@ -3378,6 +3541,7 @@ async function cmdReady(rest) {
|
|
|
3378
3541
|
const designName = readDesignName(result.dir);
|
|
3379
3542
|
const paneSuspended = !client.isPaneReadyPresentation(entry);
|
|
3380
3543
|
const engineInfoForWindow = await engines();
|
|
3544
|
+
const m365ForWindow = await m365();
|
|
3381
3545
|
|
|
3382
3546
|
if (asJson) {
|
|
3383
3547
|
process.stdout.write(
|
|
@@ -3398,6 +3562,14 @@ async function cmdReady(rest) {
|
|
|
3398
3562
|
},
|
|
3399
3563
|
paneSuspended,
|
|
3400
3564
|
engines: engineInfoForWindow,
|
|
3565
|
+
...(m365ForWindow
|
|
3566
|
+
? {
|
|
3567
|
+
m365: {
|
|
3568
|
+
signedIn: m365ForWindow.signedIn,
|
|
3569
|
+
degradation: m365ForWindow.degradation,
|
|
3570
|
+
},
|
|
3571
|
+
}
|
|
3572
|
+
: {}),
|
|
3401
3573
|
warnings: result.warnings ?? [],
|
|
3402
3574
|
nextAction: "Ready.",
|
|
3403
3575
|
},
|
|
@@ -3409,13 +3581,13 @@ async function cmdReady(rest) {
|
|
|
3409
3581
|
}
|
|
3410
3582
|
|
|
3411
3583
|
// --- Human success: compact multi-line summary on stdout. ---
|
|
3412
|
-
const
|
|
3584
|
+
const entryEnv = envTag(entry.env);
|
|
3413
3585
|
const presLabel = entry.title || entry.fileName || "(untitled)";
|
|
3414
3586
|
const designLabel = designName
|
|
3415
3587
|
? `${designName} (${result.presFP})`
|
|
3416
3588
|
: result.presFP;
|
|
3417
3589
|
process.stdout.write(`Ready — design ${designLabel}\n`);
|
|
3418
|
-
process.stdout.write(` presentation: ${presLabel}${
|
|
3590
|
+
process.stdout.write(` presentation: ${presLabel}${entryEnv}\n`);
|
|
3419
3591
|
process.stdout.write(
|
|
3420
3592
|
` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`,
|
|
3421
3593
|
);
|
|
@@ -3427,6 +3599,7 @@ async function cmdReady(rest) {
|
|
|
3427
3599
|
: " Note: pane operations (push/search/read) will wait for a pane click.\n",
|
|
3428
3600
|
);
|
|
3429
3601
|
}
|
|
3602
|
+
if (m365ForWindow) process.stdout.write(` ${m365ForWindow.line}\n`);
|
|
3430
3603
|
for (const w of result.warnings ?? []) {
|
|
3431
3604
|
process.stderr.write(` warning: ${w}\n`);
|
|
3432
3605
|
}
|
|
@@ -3477,7 +3650,6 @@ async function cmdDoctor(rest) {
|
|
|
3477
3650
|
status.authoring = {
|
|
3478
3651
|
ready: !authoringBlocker,
|
|
3479
3652
|
blocker: authoringBlocker,
|
|
3480
|
-
degraded: permissionsStaleDegradation(status, client),
|
|
3481
3653
|
engines,
|
|
3482
3654
|
};
|
|
3483
3655
|
const hostBlocker = paneBlocker(status);
|
|
@@ -3499,23 +3671,32 @@ async function cmdDoctor(rest) {
|
|
|
3499
3671
|
`Backend authoring ready: ${status.authoring.ready ? "yes" : "no"}` +
|
|
3500
3672
|
`${status.authoring.ready ? "" : ` — ${authoringBlocker.message}`}\n`,
|
|
3501
3673
|
);
|
|
3502
|
-
// One condition, one cause, named the same way everywhere: when the readiness
|
|
3503
|
-
// capabilities already say the permission snapshot has aged out, this line
|
|
3504
|
-
// says so too, instead of reporting a healthy pane-free surface that a corpus
|
|
3505
|
-
// search is about to 403 on for a reason the user learns only then.
|
|
3506
|
-
const staleEnvs = permissionsStaleEnvs(status);
|
|
3507
3674
|
process.stdout.write(
|
|
3508
3675
|
` engine search (no window needed): ${
|
|
3509
3676
|
engines.available
|
|
3510
|
-
? `yes [${engines.envs.join(", ")}]`
|
|
3511
|
-
(staleEnvs.length
|
|
3512
|
-
? ` — corpus search degraded on ${staleEnvs.join(", ")}: stale permission snapshot`
|
|
3513
|
-
: "")
|
|
3677
|
+
? `yes [${engines.envs.join(", ")}]`
|
|
3514
3678
|
: "no — relayed through an open pane"
|
|
3515
3679
|
}\n`,
|
|
3516
3680
|
);
|
|
3517
|
-
|
|
3518
|
-
|
|
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
|
+
);
|
|
3519
3700
|
}
|
|
3520
3701
|
process.stdout.write(
|
|
3521
3702
|
`Live PowerPoint ready: ${status.host.ready ? "yes" : "no"}` +
|
|
@@ -3662,16 +3843,88 @@ async function loadBridgeModule(name) {
|
|
|
3662
3843
|
return import(pathToFileURL(path.join(SRC_DIR, "bridge", `${name}.mjs`)).href);
|
|
3663
3844
|
}
|
|
3664
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
|
+
|
|
3665
3919
|
async function cmdWhoami() {
|
|
3666
3920
|
const cfg = await loadBridgeModule("config");
|
|
3667
3921
|
const sessions = cfg.readSessions();
|
|
3668
3922
|
if (sessions.length === 0) {
|
|
3669
|
-
process.stderr.write(
|
|
3670
|
-
"Not connected. In PowerPoint open slidemind → Settings → General → Connect CLI, press the link button, and run the command it shows.\n",
|
|
3671
|
-
);
|
|
3923
|
+
process.stderr.write(`${NOT_CONNECTED_LINE}\n`);
|
|
3672
3924
|
return;
|
|
3673
3925
|
}
|
|
3674
3926
|
const { httpJson } = await loadBridgeModule("http-json");
|
|
3927
|
+
const graph = await loadBridgeModule("graph-auth");
|
|
3675
3928
|
const def = cfg.getDefaultBackend();
|
|
3676
3929
|
const lines = await Promise.all(
|
|
3677
3930
|
sessions.map(async (s) => {
|
|
@@ -3690,7 +3943,12 @@ async function cmdWhoami() {
|
|
|
3690
3943
|
} catch {
|
|
3691
3944
|
detail = "unreachable — check your connection or authorize again";
|
|
3692
3945
|
}
|
|
3693
|
-
|
|
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}`;
|
|
3694
3952
|
}),
|
|
3695
3953
|
);
|
|
3696
3954
|
process.stdout.write(
|
|
@@ -3730,6 +3988,10 @@ async function cmdLogout(rest) {
|
|
|
3730
3988
|
}
|
|
3731
3989
|
|
|
3732
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);
|
|
3733
3995
|
for (const s of toRemove) {
|
|
3734
3996
|
try {
|
|
3735
3997
|
await httpJson("POST", `${s.backendURL}/api/cli/logout`, {
|
|
@@ -3738,12 +4000,21 @@ async function cmdLogout(rest) {
|
|
|
3738
4000
|
} catch {
|
|
3739
4001
|
/* best-effort server-side revoke; we still drop the local session */
|
|
3740
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);
|
|
3741
4007
|
cfg.removeSession(s.backendURL);
|
|
3742
4008
|
}
|
|
3743
4009
|
const remaining = cfg.readSessions().length;
|
|
3744
4010
|
process.stdout.write(
|
|
3745
4011
|
`Logged out of ${toRemove.map((s) => s.label).join(", ")}.` +
|
|
3746
|
-
(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
|
+
: ""),
|
|
3747
4018
|
);
|
|
3748
4019
|
}
|
|
3749
4020
|
|
|
@@ -3768,12 +4039,114 @@ async function cmdUse(rest) {
|
|
|
3768
4039
|
);
|
|
3769
4040
|
}
|
|
3770
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
|
+
|
|
3771
4142
|
async function cmdPush(rest) {
|
|
3772
4143
|
const { deck, passthrough } = splitDeckArg(rest, "push");
|
|
3773
4144
|
let to = null;
|
|
3774
4145
|
let at = null;
|
|
3775
4146
|
let replace = false;
|
|
3776
4147
|
let slides = null;
|
|
4148
|
+
let acceptConversions = false;
|
|
4149
|
+
let force = false;
|
|
3777
4150
|
for (let i = 0; i < passthrough.length; i++) {
|
|
3778
4151
|
const a = passthrough[i];
|
|
3779
4152
|
if (a === "--to") {
|
|
@@ -3788,6 +4161,10 @@ async function cmdPush(rest) {
|
|
|
3788
4161
|
at = n;
|
|
3789
4162
|
} else if (a === "--replace") {
|
|
3790
4163
|
replace = true;
|
|
4164
|
+
} else if (a === "--accept-conversions") {
|
|
4165
|
+
acceptConversions = true;
|
|
4166
|
+
} else if (a === "--force") {
|
|
4167
|
+
force = true;
|
|
3791
4168
|
} else if (a === "--slides") {
|
|
3792
4169
|
const next = passthrough[++i];
|
|
3793
4170
|
if (!next) fail("--slides expects a comma-separated list of slide ids");
|
|
@@ -3816,6 +4193,8 @@ async function cmdPush(rest) {
|
|
|
3816
4193
|
insertAt: at,
|
|
3817
4194
|
replace,
|
|
3818
4195
|
slideIds: slides,
|
|
4196
|
+
acceptConversions,
|
|
4197
|
+
force,
|
|
3819
4198
|
onProgress: renderProgressPrinter(),
|
|
3820
4199
|
});
|
|
3821
4200
|
for (const w of result.warnings ?? []) {
|
|
@@ -4206,7 +4585,35 @@ function extractBackendScope(argv) {
|
|
|
4206
4585
|
return out;
|
|
4207
4586
|
}
|
|
4208
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
|
+
|
|
4209
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
|
+
}
|
|
4210
4617
|
const argv = extractBackendScope(rawArgv);
|
|
4211
4618
|
const [cmd, ...rest] = argv;
|
|
4212
4619
|
if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
@@ -4275,6 +4682,9 @@ async function main(rawArgv) {
|
|
|
4275
4682
|
case "new":
|
|
4276
4683
|
await cmdNew(rest);
|
|
4277
4684
|
return 0;
|
|
4685
|
+
case "login":
|
|
4686
|
+
await cmdLogin(rest);
|
|
4687
|
+
break;
|
|
4278
4688
|
case "whoami":
|
|
4279
4689
|
await cmdWhoami(rest);
|
|
4280
4690
|
break;
|
|
@@ -4295,6 +4705,9 @@ async function main(rawArgv) {
|
|
|
4295
4705
|
case "read":
|
|
4296
4706
|
await cmdRead(rest);
|
|
4297
4707
|
return 0;
|
|
4708
|
+
case "adopt":
|
|
4709
|
+
await cmdAdopt(rest);
|
|
4710
|
+
return 0;
|
|
4298
4711
|
case "push":
|
|
4299
4712
|
await cmdPush(rest);
|
|
4300
4713
|
return 0;
|