slidemind 0.2.4 → 0.3.1
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 +865 -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/src/bridge/config.mjs
CHANGED
|
@@ -14,13 +14,24 @@
|
|
|
14
14
|
// The legacy v1 shape ({ backendURL, sessionToken }) is migrated transparently
|
|
15
15
|
// on read, so older installs and the raw-file test writers keep working.
|
|
16
16
|
//
|
|
17
|
+
// A record may additionally carry `msGraphRefreshToken` — the user's own Entra
|
|
18
|
+
// refresh token from `slidemind login` (see graph-auth.mjs). It is a SECOND
|
|
19
|
+
// credential with a different subject: the CLI session is minted by the add-in
|
|
20
|
+
// for whoever clicked Connect CLI, the Graph token belongs to whoever completed
|
|
21
|
+
// the browser sign-in. Written only when present, so a machine that never ran
|
|
22
|
+
// `login` keeps a byte-identical file.
|
|
23
|
+
//
|
|
17
24
|
// Stored under XDG_CONFIG_HOME (config, not cache/state) so it persists and is
|
|
18
25
|
// clearly user-scoped. 0600 — it holds bearer credentials.
|
|
19
26
|
|
|
27
|
+
import { execFileSync } from "node:child_process";
|
|
28
|
+
import crypto from "node:crypto";
|
|
20
29
|
import fs from "node:fs";
|
|
21
30
|
import os from "node:os";
|
|
22
31
|
import path from "node:path";
|
|
23
32
|
|
|
33
|
+
import { register as registerSecret } from "./secrets.mjs";
|
|
34
|
+
|
|
24
35
|
export function sessionConfigDir() {
|
|
25
36
|
// An explicitly set XDG_CONFIG_HOME wins on EVERY platform — it's a
|
|
26
37
|
// deliberate override (and how the test suite isolates the store, also on
|
|
@@ -60,6 +71,46 @@ export function deriveEnvLabel(backendURL) {
|
|
|
60
71
|
return host;
|
|
61
72
|
}
|
|
62
73
|
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// How loudly to talk about environments
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
//
|
|
78
|
+
// The env label exists because a slidemind DEVELOPER pairs local / dev / prod at
|
|
79
|
+
// once. A normal user connects exactly one backend, and for them the label on
|
|
80
|
+
// every line is noise that makes ordinary output read like a debugging session.
|
|
81
|
+
// So it is routing metadata, shown only when it actually disambiguates: more
|
|
82
|
+
// than one backend connected.
|
|
83
|
+
//
|
|
84
|
+
// Deliberately NOT gated: `--json` (agents route on it), `doctor` and `whoami`
|
|
85
|
+
// (reporting environments is their whole job), and any label the user just
|
|
86
|
+
// typed — `--backend prod`, `logout dev` — because suppressing an echo of their
|
|
87
|
+
// own argument is confusing, not quiet.
|
|
88
|
+
//
|
|
89
|
+
// Memoised: `presentations` would otherwise re-read the session file once per
|
|
90
|
+
// printed row. `writeAll` clears it, which covers every mutation (setup, login,
|
|
91
|
+
// logout, use) inside a single process.
|
|
92
|
+
let _sessionCount = null;
|
|
93
|
+
|
|
94
|
+
export function isMultiEnv() {
|
|
95
|
+
if (_sessionCount === null) _sessionCount = readSessions().length;
|
|
96
|
+
return _sessionCount > 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** " [dev]" when more than one backend is connected, "" otherwise. */
|
|
100
|
+
export function envTag(env) {
|
|
101
|
+
return env && isMultiEnv() ? ` [${env}]` : "";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* An in-sentence env mention — `envPhrase("dev", "for")` → " for dev" — or ""
|
|
106
|
+
* when there is only one environment to mean. Callers word the sentence so it
|
|
107
|
+
* reads correctly either way.
|
|
108
|
+
*/
|
|
109
|
+
export function envPhrase(env, preposition = "on") {
|
|
110
|
+
if (!env || !isMultiEnv()) return "";
|
|
111
|
+
return preposition === "on" ? ` on '${env}'` : ` ${preposition} ${env}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
63
114
|
function isValidRecord(o) {
|
|
64
115
|
return (
|
|
65
116
|
o &&
|
|
@@ -84,6 +135,20 @@ function readRaw() {
|
|
|
84
135
|
}
|
|
85
136
|
}
|
|
86
137
|
|
|
138
|
+
// Every credential this file hands out is registered for output scrubbing the
|
|
139
|
+
// moment it is read, so a token spliced into an error message downstream — a
|
|
140
|
+
// server echoing the request back, a response body used as an error detail —
|
|
141
|
+
// renders as an opaque tag instead of the value. Reading the session file is
|
|
142
|
+
// the single door every session token comes through, so this is the one place
|
|
143
|
+
// that has to remember. The `@keychain` marker is a literal, not a secret.
|
|
144
|
+
function registerRecordSecrets(record) {
|
|
145
|
+
registerSecret(record.sessionToken);
|
|
146
|
+
if (record.msGraphRefreshToken && record.msGraphRefreshToken !== KEYCHAIN_MARKER) {
|
|
147
|
+
registerSecret(record.msGraphRefreshToken);
|
|
148
|
+
}
|
|
149
|
+
return record;
|
|
150
|
+
}
|
|
151
|
+
|
|
87
152
|
// All paired sessions as [{ backendURL, sessionToken, label }]. Migrates the v1
|
|
88
153
|
// single-record shape transparently. Returns [] when not paired / unreadable.
|
|
89
154
|
export function readSessions() {
|
|
@@ -91,14 +156,18 @@ export function readSessions() {
|
|
|
91
156
|
if (!obj) return [];
|
|
92
157
|
// v2: { version, sessions: [...], default }
|
|
93
158
|
if (Array.isArray(obj.sessions)) {
|
|
94
|
-
return obj.sessions.filter(isValidRecord).map((s) => ({
|
|
159
|
+
return obj.sessions.filter(isValidRecord).map(registerRecordSecrets).map((s) => ({
|
|
95
160
|
backendURL: s.backendURL,
|
|
96
161
|
sessionToken: s.sessionToken,
|
|
97
162
|
label: typeof s.label === "string" && s.label ? s.label : deriveEnvLabel(s.backendURL),
|
|
163
|
+
...(typeof s.msGraphRefreshToken === "string" && s.msGraphRefreshToken
|
|
164
|
+
? { msGraphRefreshToken: s.msGraphRefreshToken }
|
|
165
|
+
: {}),
|
|
98
166
|
}));
|
|
99
167
|
}
|
|
100
168
|
// v1: { backendURL, sessionToken }
|
|
101
169
|
if (isValidRecord(obj)) {
|
|
170
|
+
registerRecordSecrets(obj);
|
|
102
171
|
return [
|
|
103
172
|
{
|
|
104
173
|
backendURL: obj.backendURL,
|
|
@@ -122,6 +191,7 @@ export function getDefaultBackend() {
|
|
|
122
191
|
}
|
|
123
192
|
|
|
124
193
|
function writeAll(sessions, defaultBackend) {
|
|
194
|
+
_sessionCount = null; // the count just changed — see isMultiEnv()
|
|
125
195
|
fs.mkdirSync(sessionConfigDir(), { recursive: true });
|
|
126
196
|
const payload = {
|
|
127
197
|
version: 2,
|
|
@@ -129,6 +199,7 @@ function writeAll(sessions, defaultBackend) {
|
|
|
129
199
|
backendURL: s.backendURL,
|
|
130
200
|
sessionToken: s.sessionToken,
|
|
131
201
|
label: s.label || deriveEnvLabel(s.backendURL),
|
|
202
|
+
...(s.msGraphRefreshToken ? { msGraphRefreshToken: s.msGraphRefreshToken } : {}),
|
|
132
203
|
})),
|
|
133
204
|
};
|
|
134
205
|
const def =
|
|
@@ -143,6 +214,12 @@ function writeAll(sessions, defaultBackend) {
|
|
|
143
214
|
|
|
144
215
|
// Add or replace a session (keyed by backendURL). Becomes the default if none
|
|
145
216
|
// is set yet. Preserves the existing default otherwise.
|
|
217
|
+
//
|
|
218
|
+
// Any stored `msGraphRefreshToken` is deliberately NOT carried over: redeeming
|
|
219
|
+
// a fresh claim can hand this machine to a different identity, and silently
|
|
220
|
+
// pairing that session with the previous user's delegated Graph token would
|
|
221
|
+
// fetch one person's content under another's session. `slidemind login` again
|
|
222
|
+
// is one command; a mismatched identity is not recoverable by the user.
|
|
146
223
|
export function upsertSession({ backendURL, sessionToken, label = null }) {
|
|
147
224
|
if (!backendURL || !sessionToken) {
|
|
148
225
|
throw new Error("upsertSession: backendURL and sessionToken are required");
|
|
@@ -200,6 +277,121 @@ export function getSessionFor(backendURL) {
|
|
|
200
277
|
return readSessions().find((s) => s.backendURL === backendURL) || null;
|
|
201
278
|
}
|
|
202
279
|
|
|
280
|
+
// --- Graph refresh-token at-rest storage ------------------------------------
|
|
281
|
+
//
|
|
282
|
+
// On macOS the token goes into the login Keychain (encrypted at rest, absent
|
|
283
|
+
// from file backups and accidental config-dir syncs); the session file then
|
|
284
|
+
// carries only the marker below so hasGraphSession stays a file read. Any
|
|
285
|
+
// keychain failure falls back to the plaintext field silently — the 0600 file
|
|
286
|
+
// is the universal path and stays what Linux and Windows use.
|
|
287
|
+
// ponytail: macOS-only at-rest hardening — upgrade path is libsecret on Linux
|
|
288
|
+
// and DPAPI/Credential Manager on Windows, both of which need a dependency.
|
|
289
|
+
//
|
|
290
|
+
// The `security` CLI takes the secret as an argv, which is visible in a process
|
|
291
|
+
// listing for the milliseconds the write takes — to the same user and root,
|
|
292
|
+
// i.e. the same principals who can already read the 0600 fallback file. Judged
|
|
293
|
+
// acceptable against what the keychain buys at rest.
|
|
294
|
+
//
|
|
295
|
+
// Never during tests: `node --test` sets NODE_TEST_CONTEXT, and a test writing
|
|
296
|
+
// into the developer's real login keychain would outlive the test's isolated
|
|
297
|
+
// XDG_CONFIG_HOME. SLIDEMIND_NO_KEYCHAIN is the explicit opt-out.
|
|
298
|
+
const KEYCHAIN_MARKER = "@keychain";
|
|
299
|
+
const KEYCHAIN_SERVICE = "slidemind-msgraph";
|
|
300
|
+
|
|
301
|
+
function keychainUsable() {
|
|
302
|
+
return (
|
|
303
|
+
process.platform === "darwin" &&
|
|
304
|
+
!process.env.NODE_TEST_CONTEXT &&
|
|
305
|
+
!process.env.SLIDEMIND_NO_KEYCHAIN
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Stable per-backend account key. Hashed: keychain account fields end up in
|
|
310
|
+
// `security dump-keychain` output and logs, and the backend URL is exactly the
|
|
311
|
+
// thing user-facing output must never carry.
|
|
312
|
+
function keychainAccount(backendURL) {
|
|
313
|
+
return crypto.createHash("sha256").update(String(backendURL)).digest("hex").slice(0, 32);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function keychainWrite(backendURL, token) {
|
|
317
|
+
execFileSync(
|
|
318
|
+
"security",
|
|
319
|
+
["add-generic-password", "-U", "-s", KEYCHAIN_SERVICE, "-a", keychainAccount(backendURL), "-w", token],
|
|
320
|
+
{ stdio: "ignore" },
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function keychainRead(backendURL) {
|
|
325
|
+
const out = execFileSync(
|
|
326
|
+
"security",
|
|
327
|
+
["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", keychainAccount(backendURL), "-w"],
|
|
328
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
329
|
+
);
|
|
330
|
+
const token = out.toString("utf8").trim();
|
|
331
|
+
return token || null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function keychainDelete(backendURL) {
|
|
335
|
+
try {
|
|
336
|
+
execFileSync(
|
|
337
|
+
"security",
|
|
338
|
+
["delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", keychainAccount(backendURL)],
|
|
339
|
+
{ stdio: "ignore" },
|
|
340
|
+
);
|
|
341
|
+
} catch {
|
|
342
|
+
/* nothing stored — logout of a file-stored or never-stored token */
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The user's stored Entra refresh token for one backend, or null. See
|
|
347
|
+
// graph-auth.mjs — nothing else may read it, and it never leaves this machine
|
|
348
|
+
// except as an Authorization on a token refresh.
|
|
349
|
+
export function getGraphRefreshToken(backendURL) {
|
|
350
|
+
const stored = getSessionFor(backendURL)?.msGraphRefreshToken ?? null;
|
|
351
|
+
if (stored !== KEYCHAIN_MARKER) return stored;
|
|
352
|
+
try {
|
|
353
|
+
// The file held only the marker, so `readSessions` had nothing to register —
|
|
354
|
+
// the real value first exists here.
|
|
355
|
+
const fromKeychain = keychainRead(backendURL);
|
|
356
|
+
registerSecret(fromKeychain);
|
|
357
|
+
return fromKeychain;
|
|
358
|
+
} catch {
|
|
359
|
+
// Marker without a readable keychain item (locked keychain, item removed
|
|
360
|
+
// by hand): behave as signed out rather than erroring every engine op.
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Store (or, with a null token, drop) the Graph refresh token for one backend.
|
|
366
|
+
// Every refresh returns a NEW refresh token, so this is called on each rotation.
|
|
367
|
+
// Returns false when that backend is no longer paired — a session removed
|
|
368
|
+
// mid-refresh must not resurrect itself as a token-only record.
|
|
369
|
+
export function setGraphRefreshToken(backendURL, refreshToken) {
|
|
370
|
+
const sessions = readSessions();
|
|
371
|
+
const record = sessions.find((s) => s.backendURL === backendURL);
|
|
372
|
+
if (!record) return false;
|
|
373
|
+
if (refreshToken) {
|
|
374
|
+
record.msGraphRefreshToken = refreshToken;
|
|
375
|
+
if (keychainUsable()) {
|
|
376
|
+
try {
|
|
377
|
+
keychainWrite(backendURL, refreshToken);
|
|
378
|
+
// Migration is this line: a previously plaintext field becomes the
|
|
379
|
+
// marker on the first successful keychain write.
|
|
380
|
+
record.msGraphRefreshToken = KEYCHAIN_MARKER;
|
|
381
|
+
} catch {
|
|
382
|
+
/* keychain unavailable — the plaintext field above stands */
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
} else {
|
|
386
|
+
if (record.msGraphRefreshToken === KEYCHAIN_MARKER && keychainUsable()) {
|
|
387
|
+
keychainDelete(backendURL);
|
|
388
|
+
}
|
|
389
|
+
delete record.msGraphRefreshToken;
|
|
390
|
+
}
|
|
391
|
+
writeAll(sessions, getDefaultBackend());
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
|
|
203
395
|
// Back-compat writer: upsert a single session (used by tests and any v1 caller).
|
|
204
396
|
export function writeSessionConfig({ backendURL, sessionToken }) {
|
|
205
397
|
if (!backendURL || !sessionToken) {
|