slidemind 0.3.20 → 0.4.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 +591 -15
- package/README.md +202 -7
- package/bin/slidemind.mjs +50 -2
- package/package.json +2 -2
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
package/src/bridge/config.mjs
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
// credential with a different subject: the CLI session is minted by the add-in
|
|
20
20
|
// for whoever clicked Connect CLI, the Graph token belongs to whoever completed
|
|
21
21
|
// the browser sign-in. Written only when present, so a machine that never ran
|
|
22
|
-
// `login` keeps a byte-identical file.
|
|
22
|
+
// `login` keeps a byte-identical file. `msGraphUsername` names that account (as
|
|
23
|
+
// the id_token spelled it) so `whoami` can report it without a network probe.
|
|
23
24
|
//
|
|
24
25
|
// Stored under XDG_CONFIG_HOME (config, not cache/state) so it persists and is
|
|
25
26
|
// clearly user-scoped. 0600 — it holds bearer credentials.
|
|
@@ -131,10 +132,45 @@ function readRaw() {
|
|
|
131
132
|
try {
|
|
132
133
|
return JSON.parse(raw);
|
|
133
134
|
} catch {
|
|
135
|
+
// Two CLI processes writing at once (a token refresh in each of two sim
|
|
136
|
+
// sessions, 2026-09-19) left the shorter write followed by the tail of the
|
|
137
|
+
// longer one: a complete object plus trailing bytes. The first complete
|
|
138
|
+
// top-level object is the store; anything after it is the collision.
|
|
139
|
+
const first = firstJsonObject(raw);
|
|
140
|
+
if (first) return first;
|
|
134
141
|
return null; // malformed JSON
|
|
135
142
|
}
|
|
136
143
|
}
|
|
137
144
|
|
|
145
|
+
// The first complete `{…}` at the start of `raw`, parsed — or null.
|
|
146
|
+
export function firstJsonObject(raw) {
|
|
147
|
+
const start = raw.indexOf("{");
|
|
148
|
+
if (start < 0) return null;
|
|
149
|
+
let depth = 0;
|
|
150
|
+
let inString = false;
|
|
151
|
+
for (let i = start; i < raw.length; i++) {
|
|
152
|
+
const ch = raw[i];
|
|
153
|
+
if (inString) {
|
|
154
|
+
if (ch === "\\") i++;
|
|
155
|
+
else if (ch === '"') inString = false;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (ch === '"') inString = true;
|
|
159
|
+
else if (ch === "{") depth++;
|
|
160
|
+
else if (ch === "}") {
|
|
161
|
+
depth--;
|
|
162
|
+
if (depth === 0) {
|
|
163
|
+
try {
|
|
164
|
+
return JSON.parse(raw.slice(start, i + 1));
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
138
174
|
// Every credential this file hands out is registered for output scrubbing the
|
|
139
175
|
// moment it is read, so a token spliced into an error message downstream — a
|
|
140
176
|
// server echoing the request back, a response body used as an error detail —
|
|
@@ -164,6 +200,9 @@ export function readSessions() {
|
|
|
164
200
|
...(typeof s.msGraphRefreshToken === "string" && s.msGraphRefreshToken
|
|
165
201
|
? { msGraphRefreshToken: s.msGraphRefreshToken }
|
|
166
202
|
: {}),
|
|
203
|
+
...(typeof s.msGraphUsername === "string" && s.msGraphUsername
|
|
204
|
+
? { msGraphUsername: s.msGraphUsername }
|
|
205
|
+
: {}),
|
|
167
206
|
}));
|
|
168
207
|
}
|
|
169
208
|
// v1: { backendURL, sessionToken }
|
|
@@ -202,6 +241,7 @@ function writeAll(sessions, defaultBackend) {
|
|
|
202
241
|
label: s.label || deriveEnvLabel(s.backendURL),
|
|
203
242
|
...(s.oid ? { oid: s.oid } : {}),
|
|
204
243
|
...(s.msGraphRefreshToken ? { msGraphRefreshToken: s.msGraphRefreshToken } : {}),
|
|
244
|
+
...(s.msGraphUsername ? { msGraphUsername: s.msGraphUsername } : {}),
|
|
205
245
|
})),
|
|
206
246
|
};
|
|
207
247
|
const def =
|
|
@@ -209,9 +249,12 @@ function writeAll(sessions, defaultBackend) {
|
|
|
209
249
|
? defaultBackend
|
|
210
250
|
: sessions[0]?.backendURL;
|
|
211
251
|
if (def) payload.default = def;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
252
|
+
// Atomic: write beside the store and rename over it, so a second process
|
|
253
|
+
// writing at the same moment can never interleave bytes with this one.
|
|
254
|
+
const target = sessionConfigPath();
|
|
255
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
256
|
+
fs.writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
|
257
|
+
fs.renameSync(tmp, target);
|
|
215
258
|
}
|
|
216
259
|
|
|
217
260
|
// Add or replace a session (keyed by backendURL). Becomes the default if none
|
|
@@ -248,6 +291,11 @@ export function upsertSession({ backendURL, sessionToken, label = null, oid = nu
|
|
|
248
291
|
...(samePerson && previous?.msGraphRefreshToken
|
|
249
292
|
? { msGraphRefreshToken: previous.msGraphRefreshToken }
|
|
250
293
|
: {}),
|
|
294
|
+
// The name travels with the token it describes — never on its own, or a
|
|
295
|
+
// re-pair by someone else would keep printing the previous user's account.
|
|
296
|
+
...(samePerson && previous?.msGraphRefreshToken && previous?.msGraphUsername
|
|
297
|
+
? { msGraphUsername: previous.msGraphUsername }
|
|
298
|
+
: {}),
|
|
251
299
|
});
|
|
252
300
|
const def = getDefaultBackend() || backendURL;
|
|
253
301
|
writeAll(sessions, def);
|
|
@@ -314,6 +362,11 @@ export function getSessionFor(backendURL) {
|
|
|
314
362
|
// Never during tests: `node --test` sets NODE_TEST_CONTEXT, and a test writing
|
|
315
363
|
// into the developer's real login keychain would outlive the test's isolated
|
|
316
364
|
// XDG_CONFIG_HOME. SLIDEMIND_NO_KEYCHAIN is the explicit opt-out.
|
|
365
|
+
//
|
|
366
|
+
// Never under a redirected HOME either (batch/bisect harnesses isolate runs with
|
|
367
|
+
// HOME=<run>/.home): `security` resolves the login keychain under $HOME, finds
|
|
368
|
+
// none, and macOS raises a modal "Keychain Not Found" dialog that blocks the
|
|
369
|
+
// write until someone clicks it. Such runs keep the token in their own file.
|
|
317
370
|
const KEYCHAIN_MARKER = "@keychain";
|
|
318
371
|
const KEYCHAIN_SERVICE = "slidemind-msgraph";
|
|
319
372
|
|
|
@@ -321,7 +374,8 @@ function keychainUsable() {
|
|
|
321
374
|
return (
|
|
322
375
|
process.platform === "darwin" &&
|
|
323
376
|
!process.env.NODE_TEST_CONTEXT &&
|
|
324
|
-
!process.env.SLIDEMIND_NO_KEYCHAIN
|
|
377
|
+
!process.env.SLIDEMIND_NO_KEYCHAIN &&
|
|
378
|
+
path.resolve(os.homedir()) === os.userInfo().homedir
|
|
325
379
|
);
|
|
326
380
|
}
|
|
327
381
|
|
|
@@ -385,11 +439,17 @@ export function getGraphRefreshToken(backendURL) {
|
|
|
385
439
|
// Every refresh returns a NEW refresh token, so this is called on each rotation.
|
|
386
440
|
// Returns false when that backend is no longer paired — a session removed
|
|
387
441
|
// mid-refresh must not resurrect itself as a token-only record.
|
|
388
|
-
|
|
442
|
+
//
|
|
443
|
+
// `username` is the signed-in account's name, which only the SIGN-IN knows (it
|
|
444
|
+
// reads it off the id_token). A rotation passes nothing and leaves the stored
|
|
445
|
+
// name alone; clearing the token clears the name with it, so nothing can report
|
|
446
|
+
// an account this machine no longer holds a credential for.
|
|
447
|
+
export function setGraphRefreshToken(backendURL, refreshToken, { username = null } = {}) {
|
|
389
448
|
const sessions = readSessions();
|
|
390
449
|
const record = sessions.find((s) => s.backendURL === backendURL);
|
|
391
450
|
if (!record) return false;
|
|
392
451
|
if (refreshToken) {
|
|
452
|
+
if (username) record.msGraphUsername = username;
|
|
393
453
|
record.msGraphRefreshToken = refreshToken;
|
|
394
454
|
if (keychainUsable()) {
|
|
395
455
|
try {
|
|
@@ -406,6 +466,7 @@ export function setGraphRefreshToken(backendURL, refreshToken) {
|
|
|
406
466
|
keychainDelete(backendURL);
|
|
407
467
|
}
|
|
408
468
|
delete record.msGraphRefreshToken;
|
|
469
|
+
delete record.msGraphUsername;
|
|
409
470
|
}
|
|
410
471
|
writeAll(sessions, getDefaultBackend());
|
|
411
472
|
return true;
|
|
@@ -239,16 +239,6 @@ async function streamOneResult({
|
|
|
239
239
|
markup = local.markup;
|
|
240
240
|
recolorRefused = null;
|
|
241
241
|
}
|
|
242
|
-
} else if (!iconColor && describeRecolor(markup, res.mimeType).mode === "mono") {
|
|
243
|
-
// No colour asked for and the glyph is a single fixed paint: make it
|
|
244
|
-
// TINTABLE instead of shipping it black. With `currentColor` the slide's
|
|
245
|
-
// own `color: var(--token)` on the <img> drives the fill in the preview
|
|
246
|
-
// and in the .pptx alike, so one file serves a white glyph on an accent
|
|
247
|
-
// medallion and an accent glyph on white. Every sim builder that baked
|
|
248
|
-
// one hex at download time later lost either the glyph (blue on blue)
|
|
249
|
-
// or the medallion fill; a baked colour stays available via --icon-color.
|
|
250
|
-
const tintable = applyLocalRecolor(markup, "currentColor");
|
|
251
|
-
if (tintable.applied) markup = tintable.markup;
|
|
252
242
|
}
|
|
253
243
|
const filename = `engine-${searchId}-${selection}.svg`;
|
|
254
244
|
const target = path.join(assetsDir, filename);
|
|
@@ -108,6 +108,13 @@ function tokenEndpoint(tenantId) {
|
|
|
108
108
|
return `${authorityBase()}/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// A failure that says nothing about the sign-in: the service could not be
|
|
112
|
+
// reached or was down for a moment. Only these leave a refreshFailures entry.
|
|
113
|
+
const OUTAGE_STATUS = new Set([408, 429]);
|
|
114
|
+
function unreachable(message) {
|
|
115
|
+
return Object.assign(new Error(message), { transient: true });
|
|
116
|
+
}
|
|
117
|
+
|
|
111
118
|
// One /env/config read per backend per process — the app registration doesn't
|
|
112
119
|
// change under a running command.
|
|
113
120
|
const appRegistrationCache = new Map();
|
|
@@ -122,10 +129,13 @@ async function discoverAppRegistration(backendURL) {
|
|
|
122
129
|
try {
|
|
123
130
|
res = await httpJson("GET", `${backendURL}/env/config`);
|
|
124
131
|
} catch (err) {
|
|
125
|
-
throw
|
|
132
|
+
throw unreachable(
|
|
126
133
|
`could not reach slidemind${envPhrase(env, "on")} to start sign-in: ${err?.message || err}`,
|
|
127
134
|
);
|
|
128
135
|
}
|
|
136
|
+
if (OUTAGE_STATUS.has(res.status) || res.status >= 500) {
|
|
137
|
+
throw unreachable(`slidemind${envPhrase(env, "on")} answered HTTP ${res.status} for its sign-in details`);
|
|
138
|
+
}
|
|
129
139
|
const clientId = typeof res.json?.CLIENT_ID === "string" ? res.json.CLIENT_ID : "";
|
|
130
140
|
const tenantId = typeof res.json?.TENANT_ID === "string" ? res.json.TENANT_ID : "";
|
|
131
141
|
if (res.status !== 200 || !clientId || !tenantId) {
|
|
@@ -453,7 +463,7 @@ async function requestToken(tenantId, form) {
|
|
|
453
463
|
try {
|
|
454
464
|
res = await httpJson("POST", tokenEndpoint(tenantId), { form });
|
|
455
465
|
} catch (err) {
|
|
456
|
-
throw
|
|
466
|
+
throw unreachable(`could not reach Microsoft sign-in: ${err?.message || err}`);
|
|
457
467
|
}
|
|
458
468
|
if (res.status === 200 && res.json?.access_token) {
|
|
459
469
|
// Every mint and every refresh lands here, before any caller can act on the
|
|
@@ -470,6 +480,8 @@ async function requestToken(tenantId, form) {
|
|
|
470
480
|
const err = new Error(String(detail).split("\n")[0]);
|
|
471
481
|
err.oauth = true;
|
|
472
482
|
err.code = res.json?.error ?? null;
|
|
483
|
+
// Entra down or throttling: the credential was never judged.
|
|
484
|
+
err.transient = OUTAGE_STATUS.has(res.status) || res.status >= 500;
|
|
473
485
|
throw err;
|
|
474
486
|
}
|
|
475
487
|
|
|
@@ -708,7 +720,11 @@ export async function loginGraph({
|
|
|
708
720
|
session: paired,
|
|
709
721
|
env: deriveEnvLabel(backendURL),
|
|
710
722
|
});
|
|
711
|
-
if (
|
|
723
|
+
if (
|
|
724
|
+
!setGraphRefreshToken(backendURL, tokens.refresh_token, {
|
|
725
|
+
username: accountName(claims),
|
|
726
|
+
})
|
|
727
|
+
) {
|
|
712
728
|
throw new Error(
|
|
713
729
|
`slidemind is not connected to '${deriveEnvLabel(backendURL)}'.`,
|
|
714
730
|
);
|
|
@@ -751,6 +767,24 @@ const cacheKey = (backendURL, resource) => `${backendURL}${resource}`;
|
|
|
751
767
|
// the next redeems it.
|
|
752
768
|
const refreshChain = new Map();
|
|
753
769
|
|
|
770
|
+
// Why the last refresh of a backend + resource came back empty for a reason
|
|
771
|
+
// that is NOT the credential (Entra or this backend unreachable, a timeout, an
|
|
772
|
+
// HTTP 408/429/5xx — see `unreachable`).
|
|
773
|
+
// The sign-in is intact then, and a request sent without its token is refused
|
|
774
|
+
// as `login_required`, which sends the user to sign in again for nothing (x108:
|
|
775
|
+
// "run `slidemind login`" mid-run, and the next search answered).
|
|
776
|
+
const refreshFailures = new Map(); // cacheKey → message
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* The cause of the last failed refresh of this backend's `resource` token while
|
|
780
|
+
* its sign-in is still stored, or null. Callers refuse to send a request without
|
|
781
|
+
* the token then, and report a retryable failure instead of a missing sign-in.
|
|
782
|
+
*/
|
|
783
|
+
export function tokenRefreshFailure(backendURL, resource) {
|
|
784
|
+
if (!hasGraphSession(backendURL)) return null;
|
|
785
|
+
return refreshFailures.get(cacheKey(backendURL, resource)) ?? null;
|
|
786
|
+
}
|
|
787
|
+
|
|
754
788
|
function withRefreshLock(backendURL, fn) {
|
|
755
789
|
const tail = refreshChain.get(backendURL) ?? Promise.resolve();
|
|
756
790
|
const next = tail.then(fn, fn);
|
|
@@ -781,6 +815,21 @@ export async function getGraphToken(backendURL) {
|
|
|
781
815
|
return resourceToken(backendURL, "graph");
|
|
782
816
|
}
|
|
783
817
|
|
|
818
|
+
/**
|
|
819
|
+
* This deployment's SharePoint origin (`https://tenant.sharepoint.com`), or null
|
|
820
|
+
* when it has none configured. The SAME value the SharePoint token is minted
|
|
821
|
+
* for, which is what makes it the only origin the direct adapter may send that
|
|
822
|
+
* token to — the CLI hardcodes no tenant, exactly as the connect doctrine says.
|
|
823
|
+
*/
|
|
824
|
+
export async function sharePointResource(backendURL) {
|
|
825
|
+
if (!backendURL) return null;
|
|
826
|
+
try {
|
|
827
|
+
return (await discoverAppRegistration(backendURL)).sharePointResource;
|
|
828
|
+
} catch {
|
|
829
|
+
return null;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
784
833
|
/**
|
|
785
834
|
* A usable SharePoint-resource access token for one backend, or null.
|
|
786
835
|
*
|
|
@@ -858,9 +907,12 @@ async function refreshResourceToken(backendURL, resource, storedRefreshToken) {
|
|
|
858
907
|
"corpus search and SharePoint content need the user to run `slidemind login` again.\n",
|
|
859
908
|
);
|
|
860
909
|
}
|
|
910
|
+
} else if (err?.transient) {
|
|
911
|
+
refreshFailures.set(cacheKey(backendURL, resource), err.message);
|
|
861
912
|
}
|
|
862
913
|
return null;
|
|
863
914
|
}
|
|
915
|
+
refreshFailures.delete(cacheKey(backendURL, resource));
|
|
864
916
|
// Rotation: AAD issues a new refresh token on every refresh and the old one
|
|
865
917
|
// is spent. Persisting it is what keeps the ~90-day window sliding.
|
|
866
918
|
if (tokens.refresh_token) setGraphRefreshToken(backendURL, tokens.refresh_token);
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// The `imageIntent` request envelope — the deck's approved image obligations,
|
|
2
|
+
// projected onto the wire (implementation guide §4).
|
|
3
|
+
//
|
|
4
|
+
// Two shapes, one builder:
|
|
5
|
+
//
|
|
6
|
+
// * MODEL endpoints (concept images, concept specs, spec jobs) carry the
|
|
7
|
+
// bytes INLINE as a data URI. The add-in's model call has no access to this
|
|
8
|
+
// machine, so a descriptor without bytes would be an instruction to imagine
|
|
9
|
+
// the picture.
|
|
10
|
+
// * RENDER / TRANSLATE carry `workspacePath` instead. Those requests already
|
|
11
|
+
// ship every deck asset as base64 in `deckAssets` (src/bridge/build-and-
|
|
12
|
+
// push.mjs → gatherDeckAssets, keyed `assets/<rel>`), and an image asset
|
|
13
|
+
// lives at `assets/local/<assetId>.<ext>` — which IS its workspacePath. So
|
|
14
|
+
// the envelope points into the byte map the request already has rather than
|
|
15
|
+
// sending every screenshot twice.
|
|
16
|
+
//
|
|
17
|
+
// Everything is built from what is PERSISTED — the deck's registry
|
|
18
|
+
// (state/image-assets.json), the plan's storyboard fences, and the targeted-edit
|
|
19
|
+
// overrides — never from whatever a command happens to be holding. A use that
|
|
20
|
+
// only exists in a command's arguments is a use the user has not approved.
|
|
21
|
+
//
|
|
22
|
+
// The envelope is returned through the contract's own `parseImageIntentEnvelope`,
|
|
23
|
+
// so a request can never carry a record the receiving validator would reject,
|
|
24
|
+
// and a deck with no image uses builds NO envelope at all: `null`, so the call
|
|
25
|
+
// serializes exactly as it did before this feature existed.
|
|
26
|
+
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
|
|
30
|
+
import { splitPlanMarkdownIntoSegments } from "../builders/storyboard-markdown.mjs";
|
|
31
|
+
import {
|
|
32
|
+
imageAssetError,
|
|
33
|
+
readImageAssetManifest,
|
|
34
|
+
resolveImageAsset,
|
|
35
|
+
} from "../lib/image-assets.mjs";
|
|
36
|
+
import {
|
|
37
|
+
IMAGE_INTENT_VERSION,
|
|
38
|
+
parseImageIntentEnvelope,
|
|
39
|
+
selectImageIntent,
|
|
40
|
+
} from "../lib/image-intent.mjs";
|
|
41
|
+
import { imageIntentFingerprint } from "../lib/image-intent-fingerprint.mjs";
|
|
42
|
+
import { readImageUseOverrides, slideIdentities } from "../lib/image-use-overrides.mjs";
|
|
43
|
+
|
|
44
|
+
// The ceiling a SINGLE source may carry into one request. It is the add-in's
|
|
45
|
+
// own `MAX_IMAGE_ASSET_BYTES` (RT/image-assets.ts), which is the ceiling
|
|
46
|
+
// `engine_add` and `generate_image` already enforce server-side — reused rather
|
|
47
|
+
// than re-invented, so an asset this CLI accepts is one the route accepts. Over
|
|
48
|
+
// it, the answer is the contract's `image_asset_too_large`: produce a declared
|
|
49
|
+
// derivative or supply a smaller source. The final source is never silently
|
|
50
|
+
// downsampled and the asset list is never silently shortened.
|
|
51
|
+
export const IMAGE_INTENT_MAX_ASSET_BYTES = 8 * 1024 * 1024;
|
|
52
|
+
|
|
53
|
+
// The encoded-body target a batched request aims to stay under. Same number,
|
|
54
|
+
// different job: it is what the per-slide batching in `slidemind concepts`
|
|
55
|
+
// already assumed ("four slides keep each body under ~8 MB") now that an
|
|
56
|
+
// envelope's inline sources ride along with the mockup PNGs.
|
|
57
|
+
export const IMAGE_INTENT_REQUEST_BYTE_BUDGET = 8 * 1024 * 1024;
|
|
58
|
+
|
|
59
|
+
const mb = (n) => (n / (1024 * 1024)).toFixed(1);
|
|
60
|
+
|
|
61
|
+
function readPlanMarkdown(deckDir) {
|
|
62
|
+
try {
|
|
63
|
+
return fs.readFileSync(path.join(deckDir, "plan.md"), "utf8");
|
|
64
|
+
} catch {
|
|
65
|
+
return "";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The uses each storyboard fence records: `Map<slide, AssetUse[]>`. */
|
|
70
|
+
export function planStoryboardUses(planMarkdown) {
|
|
71
|
+
const bySlide = new Map();
|
|
72
|
+
for (const segment of splitPlanMarkdownIntoSegments(planMarkdown ?? "")) {
|
|
73
|
+
if (segment.type !== "storyboard" || !segment.data?.slide) continue;
|
|
74
|
+
bySlide.set(segment.data.slide, segment.data.assetUses ?? []);
|
|
75
|
+
}
|
|
76
|
+
return bySlide;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The uses that actually apply to each requested slide: its targeted-edit
|
|
81
|
+
* override if it has one, otherwise the plan's.
|
|
82
|
+
*
|
|
83
|
+
* This is `effectiveSlideUses` (src/lib/image-use-overrides.mjs) for a whole
|
|
84
|
+
* slide set — composed from the same two primitives rather than called per
|
|
85
|
+
* slide, because the slide-identity half rescans and re-hashes every slide file
|
|
86
|
+
* on each call and a 20-slide deck would pay for it twenty times.
|
|
87
|
+
*
|
|
88
|
+
* @returns {Map<number, Array>}
|
|
89
|
+
*/
|
|
90
|
+
export function effectiveUsesForSlides(deckDir, slides, { planMarkdown = null } = {}) {
|
|
91
|
+
const planned = planStoryboardUses(planMarkdown ?? readPlanMarkdown(deckDir));
|
|
92
|
+
const wanted = normalizeSlides(slides);
|
|
93
|
+
const out = new Map();
|
|
94
|
+
if (!wanted.length) return out;
|
|
95
|
+
|
|
96
|
+
// Only pay for the override machinery when the deck has overrides at all —
|
|
97
|
+
// which, for a planned deck that was never edited slide-by-slide, is never.
|
|
98
|
+
const overrides = readImageUseOverrides(deckDir);
|
|
99
|
+
const idBySlide = new Map();
|
|
100
|
+
if (overrides.slides.length) {
|
|
101
|
+
for (const entry of slideIdentities(deckDir)) idBySlide.set(entry.slide, entry.slideId);
|
|
102
|
+
}
|
|
103
|
+
for (const slide of wanted) {
|
|
104
|
+
const { uses } = selectImageIntent({
|
|
105
|
+
slideId: idBySlide.get(slide) ?? null,
|
|
106
|
+
storyboardUses: planned.get(slide) ?? [],
|
|
107
|
+
overrides,
|
|
108
|
+
});
|
|
109
|
+
out.set(slide, uses);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizeSlides(slides) {
|
|
115
|
+
return [...new Set(slides ?? [])]
|
|
116
|
+
.filter((n) => Number.isInteger(n) && n > 0)
|
|
117
|
+
.sort((a, b) => a - b);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// One asset's transport descriptor, with its bytes proved: `resolveImageAsset`
|
|
121
|
+
// raises `image_asset_missing` when the copy is gone and `image_asset_changed`
|
|
122
|
+
// when it no longer hashes to what the registry recorded — the two failures the
|
|
123
|
+
// contract distinguishes because their repairs differ.
|
|
124
|
+
function assetDescriptor(deckDir, assetId, { inline, maxAssetBytes }) {
|
|
125
|
+
const { asset, bytes } = resolveImageAsset(deckDir, assetId);
|
|
126
|
+
if (bytes.length > maxAssetBytes) {
|
|
127
|
+
throw imageAssetError(
|
|
128
|
+
"image_asset_too_large",
|
|
129
|
+
`${asset.displayName}: ${mb(bytes.length)} MB is past the ${mb(maxAssetBytes)} MB a single image may carry into one request`,
|
|
130
|
+
{ assetId },
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const descriptor = {
|
|
134
|
+
assetId: asset.assetId,
|
|
135
|
+
sha256: asset.sha256,
|
|
136
|
+
mimeType: asset.mimeType,
|
|
137
|
+
width: asset.width,
|
|
138
|
+
height: asset.height,
|
|
139
|
+
byteLength: bytes.length,
|
|
140
|
+
displayName: asset.displayName,
|
|
141
|
+
};
|
|
142
|
+
return inline
|
|
143
|
+
? { ...descriptor, image: `data:${asset.mimeType};base64,${bytes.toString("base64")}` }
|
|
144
|
+
: { ...descriptor, workspacePath: asset.workspacePath };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The `imageIntent` envelope for one request, or **null** when none of the
|
|
149
|
+
* requested slides carries an image use.
|
|
150
|
+
*
|
|
151
|
+
* Null is the load-bearing case: it is what keeps every existing call — every
|
|
152
|
+
* deck that has no image intent, which today is all of them — serializing byte
|
|
153
|
+
* for byte as it did before.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} deckDir the deck workspace
|
|
156
|
+
* @param {{slides: number[], planMarkdown?: string|null, inline?: boolean,
|
|
157
|
+
* maxAssetBytes?: number}} opts
|
|
158
|
+
* `inline: true` (the default) carries data URIs for the model endpoints;
|
|
159
|
+
* `inline: false` carries `workspacePath`, for render/translate, whose
|
|
160
|
+
* `deckAssets` byte map already holds those exact bytes under that key.
|
|
161
|
+
*/
|
|
162
|
+
export function buildImageIntentEnvelope(
|
|
163
|
+
deckDir,
|
|
164
|
+
{ slides, planMarkdown = null, inline = true, maxAssetBytes = IMAGE_INTENT_MAX_ASSET_BYTES } = {},
|
|
165
|
+
) {
|
|
166
|
+
const uses = effectiveUsesForSlides(deckDir, slides, { planMarkdown });
|
|
167
|
+
const slidesOut = [];
|
|
168
|
+
const assetIds = new Set();
|
|
169
|
+
for (const slide of normalizeSlides([...uses.keys()])) {
|
|
170
|
+
const list = uses.get(slide) ?? [];
|
|
171
|
+
if (!list.length) continue;
|
|
172
|
+
slidesOut.push({ slide, uses: list });
|
|
173
|
+
for (const use of list) assetIds.add(use.assetId);
|
|
174
|
+
}
|
|
175
|
+
if (!slidesOut.length) return null;
|
|
176
|
+
|
|
177
|
+
const envelope = {
|
|
178
|
+
version: IMAGE_INTENT_VERSION,
|
|
179
|
+
// Only the assets the REQUESTED slides use, in a deterministic order so two
|
|
180
|
+
// identical requests serialize identically.
|
|
181
|
+
assets: [...assetIds]
|
|
182
|
+
.sort()
|
|
183
|
+
.map((assetId) => assetDescriptor(deckDir, assetId, { inline, maxAssetBytes })),
|
|
184
|
+
slides: slidesOut,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// One validator on both sides of the wire: whatever this builds is run
|
|
188
|
+
// through the same parser the receiver uses, so a drift here fails here.
|
|
189
|
+
const parsed = parseImageIntentEnvelope(envelope);
|
|
190
|
+
if (!parsed.value) {
|
|
191
|
+
const first = parsed.issues[0];
|
|
192
|
+
throw imageAssetError(
|
|
193
|
+
first?.code ?? "image_intent_invalid",
|
|
194
|
+
`image intent envelope: ${first?.message ?? "invalid"}`,
|
|
195
|
+
first?.assetId ? { assetId: first.assetId } : {},
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return parsed.value;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The canonical intent fingerprint of each requested slide — `null` for a slide
|
|
203
|
+
* with no uses, so a legacy no-intent slide has nothing to record and nothing
|
|
204
|
+
* to invalidate (design decision 6).
|
|
205
|
+
*
|
|
206
|
+
* Reads the registry, never the bytes: an entry is immutable, so its recorded
|
|
207
|
+
* digest IS the source identity. Whether the copy on disk still matches is a
|
|
208
|
+
* different question, answered where the bytes are actually needed.
|
|
209
|
+
*
|
|
210
|
+
* @returns {Map<number, string|null>}
|
|
211
|
+
*/
|
|
212
|
+
export function slideIntentFingerprints(deckDir, slides, { planMarkdown = null, context = null } = {}) {
|
|
213
|
+
const uses = effectiveUsesForSlides(deckDir, slides, { planMarkdown });
|
|
214
|
+
const out = new Map();
|
|
215
|
+
if (![...uses.values()].some((list) => list.length)) {
|
|
216
|
+
for (const slide of uses.keys()) out.set(slide, null);
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
const manifest = readImageAssetManifest(deckDir);
|
|
220
|
+
const byId = new Map(manifest.assets.map((asset) => [asset.assetId, asset]));
|
|
221
|
+
for (const [slide, list] of uses) {
|
|
222
|
+
if (!list.length) {
|
|
223
|
+
out.set(slide, null);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const assets = [...new Set(list.map((use) => use.assetId))]
|
|
227
|
+
.map((id) => byId.get(id))
|
|
228
|
+
.filter(Boolean);
|
|
229
|
+
out.set(
|
|
230
|
+
slide,
|
|
231
|
+
imageIntentFingerprint({
|
|
232
|
+
uses: list,
|
|
233
|
+
assets,
|
|
234
|
+
context: { ...(context ?? {}), slide },
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { previewPath } from '../lib/deck-paths.mjs';
|
|
5
|
+
import { slideOrderPaths } from '../pptx/ooxml.mjs';
|
|
6
|
+
import { unzip } from '../pptx/zip.mjs';
|
|
7
|
+
import { backendCapabilities, bridgeFetch, withTransientRetry } from './client/transport.mjs';
|
|
8
|
+
import { multipartBody } from './client/brands.mjs';
|
|
9
|
+
|
|
10
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
11
|
+
|
|
12
|
+
export function validatePackagePreview(bytes, result) {
|
|
13
|
+
const count = slideOrderPaths(unzip(bytes)).length;
|
|
14
|
+
if (!count || result?.packageSha256 !== digest(bytes) || result?.renderer !== 'libreoffice')
|
|
15
|
+
throw new Error('Package preview does not identify the exported PPTX bytes and renderer.');
|
|
16
|
+
const slides = [...(result.images ?? [])].sort((a,b)=>a.slideNumber-b.slideNumber);
|
|
17
|
+
if (slides.length !== count || slides.some((s,i)=>s.slideNumber!==i+1))
|
|
18
|
+
throw new Error('Package preview is incomplete or contains duplicate/out-of-order slide identities.');
|
|
19
|
+
return slides.map(s => {
|
|
20
|
+
const png = Buffer.from(s.base64 ?? '', 'base64');
|
|
21
|
+
if (!png.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])))
|
|
22
|
+
throw new Error(`Package preview slide ${s.slideNumber} is not a PNG.`);
|
|
23
|
+
return { slideNumber:s.slideNumber, png };
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The exact final bytes, including native/reused slides and assembly changes.
|
|
28
|
+
// A browser capture is NEVER reported as a render of this package.
|
|
29
|
+
// The backend renders at most a few packages at once and answers 429
|
|
30
|
+
// preview_busy beyond that. A render holds its slot for tens of seconds, so a
|
|
31
|
+
// busy answer waits long enough for one to finish instead of giving up.
|
|
32
|
+
const BUSY_DELAYS_MS = [10000, 20000, 40000];
|
|
33
|
+
const isBusy = err => err?.status === 429 && err?.code === 'preview_busy';
|
|
34
|
+
|
|
35
|
+
export async function writePackagePreview({deckDir, bytes, target, onProgress, request, busyDelays = BUSY_DELAYS_MS}) {
|
|
36
|
+
const packageSha256 = digest(bytes);
|
|
37
|
+
const unavailable = note => ({ kind:'pptx', renderer:null, status:'unavailable', packageSha256, dir:null, files:[], note });
|
|
38
|
+
if (!target?.brandId || !target.backendURL) return unavailable('Exported PPTX visual verification unavailable: no brand render target.');
|
|
39
|
+
try {
|
|
40
|
+
if (!request) {
|
|
41
|
+
const caps = await backendCapabilities(target.backendURL);
|
|
42
|
+
if (!caps.pptxPreview) return unavailable('Exported PPTX visual verification unavailable: this backend does not provide package previews. Browser previews verify source only.');
|
|
43
|
+
request = async () => {
|
|
44
|
+
const upload = multipartBody({}, {name:'deck', fileName:'deck.pptx', bytes, contentType:'application/vnd.openxmlformats-officedocument.presentationml.presentation'});
|
|
45
|
+
const response = await bridgeFetch(`/api/cli/brands/${encodeURIComponent(target.brandId)}/preview-pptx`,{method:'POST',headers:{'content-type':upload.contentType},body:upload.body,timeoutMs:200000},target.backendURL);
|
|
46
|
+
return response.json();
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
onProgress?.({stage:'verifying exported PPTX',done:0,total:1});
|
|
50
|
+
const result = await withTransientRetry(() => request(bytes, target), isBusy, busyDelays);
|
|
51
|
+
const slides = validatePackagePreview(bytes,result);
|
|
52
|
+
// Content-addressed directory: a failed later build cannot leave old PNGs
|
|
53
|
+
// looking like successful verification of new bytes.
|
|
54
|
+
const dir = path.join(previewPath(deckDir,'package'),packageSha256);
|
|
55
|
+
fs.mkdirSync(dir,{recursive:true});
|
|
56
|
+
const files = slides.map(({slideNumber,png})=>{
|
|
57
|
+
const file=path.join(dir,`${String(slideNumber).padStart(2,'0')}.png`);
|
|
58
|
+
fs.writeFileSync(file,png);return file;
|
|
59
|
+
});
|
|
60
|
+
const preview={kind:'pptx',renderer:result.renderer,status:'rendered',packageSha256,dir,files,
|
|
61
|
+
note:'Rendered from the exported PPTX with LibreOffice. Inspect every slide; this is not a PowerPoint pixel-parity or visual-quality verdict.'};
|
|
62
|
+
fs.writeFileSync(path.join(dir,'manifest.json'),JSON.stringify(preview,null,2)+'\n');
|
|
63
|
+
return preview;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return unavailable(`Exported PPTX visual verification unavailable: ${err instanceof Error ? err.message : String(err)}. Browser previews verify source only.`);
|
|
66
|
+
}
|
|
67
|
+
}
|