slidemind 0.0.29 → 0.1.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.
@@ -0,0 +1,214 @@
1
+ // CLI session config: where `slidemind pair` stores the cloud relay endpoint(s)
2
+ // + the opaque session token each was issued. The CLI talks to
3
+ // <backendURL>/api/cli/* with `Authorization: Bearer <sessionToken>`; no
4
+ // localhost, no cert.
5
+ //
6
+ // Multi-environment by design: a slidemind developer can pair the CLI with
7
+ // several backends (local / dev / prod) at once. Each is one record in
8
+ // `sessions[]`; commands fan out across all of them and route by the
9
+ // presentation's origin backend (see client.mjs). One `default` backend is the
10
+ // fallback for the rare single-backend accessor.
11
+ //
12
+ // On-disk shape (v2):
13
+ // { version: 2, sessions: [{ backendURL, sessionToken, label }], default }
14
+ // The legacy v1 shape ({ backendURL, sessionToken }) is migrated transparently
15
+ // on read, so older installs and the raw-file test writers keep working.
16
+ //
17
+ // Stored under XDG_CONFIG_HOME (config, not cache/state) so it persists and is
18
+ // clearly user-scoped. 0600 — it holds bearer credentials.
19
+
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+
24
+ export function sessionConfigDir() {
25
+ if (process.platform === "win32") {
26
+ const base =
27
+ process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
28
+ return path.join(base, "slidemind");
29
+ }
30
+ const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
31
+ return path.join(base, "slidemind");
32
+ }
33
+
34
+ export function sessionConfigPath() {
35
+ return path.join(sessionConfigDir(), "session.json");
36
+ }
37
+
38
+ // A short, human env label for a backend URL: localhost → "local", a hostname
39
+ // containing "dev"/"staging" → that word, otherwise the bare hostname. Used in
40
+ // listings and to let `--backend <env>` / `slidemind use <env>` match by name.
41
+ export function deriveEnvLabel(backendURL) {
42
+ let host;
43
+ try {
44
+ host = new URL(backendURL).hostname;
45
+ } catch {
46
+ return String(backendURL || "");
47
+ }
48
+ if (host === "localhost" || host === "127.0.0.1" || host === "::1") {
49
+ return "local";
50
+ }
51
+ if (/(^|[.-])dev([.-]|$)/i.test(host) || /dev/i.test(host)) return "dev";
52
+ if (/stag(ing|e)?|stg/i.test(host)) return "staging";
53
+ if (/prod/i.test(host)) return "prod";
54
+ return host;
55
+ }
56
+
57
+ function isValidRecord(o) {
58
+ return (
59
+ o &&
60
+ typeof o.backendURL === "string" &&
61
+ typeof o.sessionToken === "string" &&
62
+ o.backendURL &&
63
+ o.sessionToken
64
+ );
65
+ }
66
+
67
+ function readRaw() {
68
+ let raw;
69
+ try {
70
+ raw = fs.readFileSync(sessionConfigPath(), "utf8");
71
+ } catch {
72
+ return null; // ENOENT or unreadable → treat as not paired
73
+ }
74
+ try {
75
+ return JSON.parse(raw);
76
+ } catch {
77
+ return null; // malformed JSON
78
+ }
79
+ }
80
+
81
+ // All paired sessions as [{ backendURL, sessionToken, label }]. Migrates the v1
82
+ // single-record shape transparently. Returns [] when not paired / unreadable.
83
+ export function readSessions() {
84
+ const obj = readRaw();
85
+ if (!obj) return [];
86
+ // v2: { version, sessions: [...], default }
87
+ if (Array.isArray(obj.sessions)) {
88
+ return obj.sessions.filter(isValidRecord).map((s) => ({
89
+ backendURL: s.backendURL,
90
+ sessionToken: s.sessionToken,
91
+ label: typeof s.label === "string" && s.label ? s.label : deriveEnvLabel(s.backendURL),
92
+ }));
93
+ }
94
+ // v1: { backendURL, sessionToken }
95
+ if (isValidRecord(obj)) {
96
+ return [
97
+ {
98
+ backendURL: obj.backendURL,
99
+ sessionToken: obj.sessionToken,
100
+ label: deriveEnvLabel(obj.backendURL),
101
+ },
102
+ ];
103
+ }
104
+ return [];
105
+ }
106
+
107
+ // The backendURL marked default, or null. Falls back to the first session when
108
+ // the stored default is missing/stale.
109
+ export function getDefaultBackend() {
110
+ const obj = readRaw();
111
+ const sessions = readSessions();
112
+ if (sessions.length === 0) return null;
113
+ const stored = obj && typeof obj.default === "string" ? obj.default : null;
114
+ if (stored && sessions.some((s) => s.backendURL === stored)) return stored;
115
+ return sessions[0].backendURL;
116
+ }
117
+
118
+ function writeAll(sessions, defaultBackend) {
119
+ fs.mkdirSync(sessionConfigDir(), { recursive: true });
120
+ const payload = {
121
+ version: 2,
122
+ sessions: sessions.map((s) => ({
123
+ backendURL: s.backendURL,
124
+ sessionToken: s.sessionToken,
125
+ label: s.label || deriveEnvLabel(s.backendURL),
126
+ })),
127
+ };
128
+ const def =
129
+ defaultBackend && sessions.some((s) => s.backendURL === defaultBackend)
130
+ ? defaultBackend
131
+ : sessions[0]?.backendURL;
132
+ if (def) payload.default = def;
133
+ fs.writeFileSync(sessionConfigPath(), `${JSON.stringify(payload, null, 2)}\n`, {
134
+ mode: 0o600,
135
+ });
136
+ }
137
+
138
+ // Add or replace a session (keyed by backendURL). Becomes the default if none
139
+ // is set yet. Preserves the existing default otherwise.
140
+ export function upsertSession({ backendURL, sessionToken, label = null }) {
141
+ if (!backendURL || !sessionToken) {
142
+ throw new Error("upsertSession: backendURL and sessionToken are required");
143
+ }
144
+ const sessions = readSessions().filter((s) => s.backendURL !== backendURL);
145
+ sessions.push({
146
+ backendURL,
147
+ sessionToken,
148
+ label: label || deriveEnvLabel(backendURL),
149
+ });
150
+ const def = getDefaultBackend() || backendURL;
151
+ writeAll(sessions, def);
152
+ return sessions;
153
+ }
154
+
155
+ // Remove one session by backendURL. Returns true if a record was removed. If it
156
+ // was the default, the first remaining session becomes the new default.
157
+ export function removeSession(backendURL) {
158
+ const sessions = readSessions();
159
+ const next = sessions.filter((s) => s.backendURL !== backendURL);
160
+ if (next.length === sessions.length) return false;
161
+ if (next.length === 0) {
162
+ clearSessionConfig();
163
+ return true;
164
+ }
165
+ let def = getDefaultBackend();
166
+ if (def === backendURL) def = next[0].backendURL;
167
+ writeAll(next, def);
168
+ return true;
169
+ }
170
+
171
+ export function setDefaultBackend(backendURL) {
172
+ const sessions = readSessions();
173
+ if (!sessions.some((s) => s.backendURL === backendURL)) {
174
+ throw new Error(
175
+ `setDefaultBackend: not paired with '${deriveEnvLabel(backendURL)}'`,
176
+ );
177
+ }
178
+ writeAll(sessions, backendURL);
179
+ }
180
+
181
+ // Back-compat single-session accessor: the default session as
182
+ // { backendURL, sessionToken }, or null. Load-bearing for the bridgeFetch
183
+ // fallback and any caller that wants "the one session".
184
+ export function readSessionConfig() {
185
+ const sessions = readSessions();
186
+ if (sessions.length === 0) return null;
187
+ const def = getDefaultBackend();
188
+ const pick = sessions.find((s) => s.backendURL === def) || sessions[0];
189
+ return { backendURL: pick.backendURL, sessionToken: pick.sessionToken };
190
+ }
191
+
192
+ // The full session record (incl. token) for a specific backend, or null.
193
+ export function getSessionFor(backendURL) {
194
+ return readSessions().find((s) => s.backendURL === backendURL) || null;
195
+ }
196
+
197
+ // Back-compat writer: upsert a single session (used by tests and any v1 caller).
198
+ export function writeSessionConfig({ backendURL, sessionToken }) {
199
+ if (!backendURL || !sessionToken) {
200
+ throw new Error("writeSessionConfig: backendURL and sessionToken are required");
201
+ }
202
+ upsertSession({ backendURL, sessionToken });
203
+ }
204
+
205
+ // Remove the stored session file entirely (all sessions). Returns true if a
206
+ // file was deleted.
207
+ export function clearSessionConfig() {
208
+ try {
209
+ fs.rmSync(sessionConfigPath());
210
+ return true;
211
+ } catch {
212
+ return false;
213
+ }
214
+ }
@@ -0,0 +1,67 @@
1
+ // Pairing + session helpers for the CLI side of the cloud relay.
2
+ //
3
+ // `slidemind pair <blob>` decodes {u: backendURL, s: secret} and redeems the
4
+ // one-time secret for an opaque session token, stored by config.mjs. whoami/
5
+ // logout call the session-gated endpoints with that token.
6
+
7
+ import http from "node:http";
8
+ import https from "node:https";
9
+
10
+ function isLoopbackHostname(h) {
11
+ return h === "127.0.0.1" || h === "localhost" || h === "::1";
12
+ }
13
+
14
+ // Minimal JSON request. Validates the cloud's real cert; skips only for a
15
+ // loopback dev backend (self-signed).
16
+ export function httpJson(method, urlStr, { body = null, token = null } = {}) {
17
+ const url = new URL(urlStr);
18
+ const transport = url.protocol === "https:" ? https : http;
19
+ const payload = body != null ? JSON.stringify(body) : null;
20
+ const headers = { accept: "application/json" };
21
+ if (payload) {
22
+ headers["content-type"] = "application/json";
23
+ headers["content-length"] = Buffer.byteLength(payload);
24
+ }
25
+ if (token) headers.authorization = `Bearer ${token}`;
26
+ return new Promise((resolve, reject) => {
27
+ const req = transport.request(
28
+ url,
29
+ {
30
+ method,
31
+ headers,
32
+ rejectUnauthorized:
33
+ url.protocol === "https:" ? !isLoopbackHostname(url.hostname) : undefined,
34
+ },
35
+ (res) => {
36
+ const chunks = [];
37
+ res.on("data", (c) => chunks.push(Buffer.from(c)));
38
+ res.on("end", () => {
39
+ const text = Buffer.concat(chunks).toString("utf8");
40
+ let json = null;
41
+ try {
42
+ json = text ? JSON.parse(text) : null;
43
+ } catch {
44
+ /* non-JSON body */
45
+ }
46
+ resolve({ status: res.statusCode ?? 0, json, text });
47
+ });
48
+ },
49
+ );
50
+ req.on("error", reject);
51
+ if (payload) req.write(payload);
52
+ req.end();
53
+ });
54
+ }
55
+
56
+ // Decode a `slidemind pair <blob>` code → { backendURL, secret } or null.
57
+ export function decodePairingCode(code) {
58
+ try {
59
+ const obj = JSON.parse(Buffer.from(code, "base64url").toString("utf8"));
60
+ if (obj && typeof obj.u === "string" && typeof obj.s === "string") {
61
+ return { backendURL: obj.u, secret: obj.s };
62
+ }
63
+ } catch {
64
+ /* malformed */
65
+ }
66
+ return null;
67
+ }
@@ -34,6 +34,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
34
34
 
35
35
  import { lintSlidesOrThrow } from "./lint-slides.mjs";
36
36
  import { resolveSkillForDeck } from "./resolve-skill.mjs";
37
+ import { slideOrderWarning, sortSlideFilenames } from "../lib/slide-order.mjs";
37
38
 
38
39
  const __filename = fileURLToPath(import.meta.url);
39
40
  const __dirname = path.dirname(__filename);
@@ -606,14 +607,17 @@ export function buildDeck(deckDir, opts = {}) {
606
607
  `${path.basename(deckDir)}' to produce the editable .pptx.`,
607
608
  );
608
609
  }
609
- const slideFiles = fs
610
- .readdirSync(slidesDir)
611
- .filter((f) => f.endsWith(".jsx"))
612
- .sort()
613
- .map((f) => path.join(slidesDir, f));
610
+ const slideNames = sortSlideFilenames(
611
+ fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx")),
612
+ );
613
+ const slideFiles = slideNames.map((f) => path.join(slidesDir, f));
614
614
  if (slideFiles.length === 0) {
615
615
  throw new Error(`No .jsx files in ${slidesDir}`);
616
616
  }
617
+ const orderWarning = slideOrderWarning(slideNames);
618
+ if (orderWarning) {
619
+ process.stderr.write(`warning: ${orderWarning}\n`);
620
+ }
617
621
 
618
622
  // Layout discovery: brand layouts first, then the active skill's layouts
619
623
  // override on basename collision — so a sibling skill could reach for the
@@ -0,0 +1,28 @@
1
+ // Slide play order over the CLI is FILENAME order. The in-app design agent
2
+ // passes an explicit ordered list to its `build_and_push` tool; the CLI has no
3
+ // such channel, so decks encode order with zero-padded numeric prefixes
4
+ // (01-cover.jsx, 02-agenda.jsx, …) — see skills/create-slides/SKILL.md
5
+ // (command mapping). The sort is numeric-aware so unpadded prefixes still
6
+ // order correctly (2-foo.jsx before 10-bar.jsx).
7
+
8
+ const collator = new Intl.Collator("en", { numeric: true });
9
+
10
+ export function sortSlideFilenames(filenames) {
11
+ return [...filenames].sort(collator.compare);
12
+ }
13
+
14
+ // Warn (never fail) when a multi-slide deck has slide files without a numeric
15
+ // prefix: their play order is accidental-alphabetical, which is the #1 way a
16
+ // deck comes out shuffled.
17
+ export function slideOrderWarning(filenames) {
18
+ if (filenames.length < 2) return null;
19
+ const unprefixed = filenames.filter((f) => !/^\d+[-_]/.test(f));
20
+ if (unprefixed.length === 0) return null;
21
+ const sample = unprefixed.slice(0, 3).join(", ");
22
+ const more = unprefixed.length > 3 ? ", …" : "";
23
+ return (
24
+ `slide order is filename order, but ${unprefixed.length} of ` +
25
+ `${filenames.length} slide files lack a numeric prefix (${sample}${more}) — ` +
26
+ `rename to NN-<topic>.jsx (01-cover.jsx, 02-…) so play order matches intent`
27
+ );
28
+ }
@@ -22,10 +22,9 @@
22
22
  // Fetches a chosen search result's bytes and writes them into a deck's
23
23
  // assets/ folder for the slide JSX to reference.
24
24
  //
25
- // Everything routes through the add-in's local bridge at
26
- // https://127.0.0.1:3001/local/* with a shared token the add-in writes to
27
- // `${XDG_STATE_HOME:-~/.local/state}/slidemind/bridge-token`
28
- // (Windows: `%LOCALAPPDATA%\\slidemind\\bridge-token`). See src/bridge/client.mjs.
25
+ // Everything routes through the add-in's cloud relay at
26
+ // `<backendURL>/api/cli/*` with the Bearer session token established by
27
+ // `slidemind pair`. See src/bridge/client.mjs.
29
28
 
30
29
  import fs from "node:fs";
31
30
  import path from "node:path";
@@ -132,6 +131,9 @@ const server = new McpServer(
132
131
  "with an active corporate design is required, and all brand rules come from that " +
133
132
  "design's bundle (design.md), never hardcoded here. " +
134
133
  "Always call slidemind_list_open_presentations first to learn which presentation_id to target. " +
134
+ "The CLI may be paired with several add-in environments (local/dev/prod) at once; the list spans " +
135
+ "all of them and each entry is tagged with its env, and routing to the right backend is automatic — " +
136
+ "just pass the presentation_id back. " +
135
137
  "For brand imagery, slidemind_search_engine then slidemind_download_result.",
136
138
  },
137
139
  );
@@ -142,15 +144,16 @@ server.registerTool(
142
144
  title: "List open PowerPoint windows",
143
145
  description:
144
146
  "List PowerPoint windows registered with the slidemind local bridge, including retained entries whose task-pane socket is temporarily disconnected. " +
145
- "Each entry has a stable presId — pass it as `presentation_id` to slidemind_push_deck. " +
147
+ "Spans every paired add-in environment (local/dev/prod); each entry carries an `env` tag and a stable presId — pass the presId as `presentation_id` to the other tools and routing to that env is automatic. " +
146
148
  "If the list is empty the user has no PowerPoint open with the add-in loaded.",
147
149
  inputSchema: {},
148
150
  },
149
151
  async () => {
150
152
  try {
151
- const { listOpenPresentations } = await loadBridgeClient();
153
+ const { listOpenPresentations, toPublicPresentation } =
154
+ await loadBridgeClient();
152
155
  const list = await listOpenPresentations();
153
- return textResult({ presentations: list });
156
+ return textResult({ presentations: list.map(toPublicPresentation) });
154
157
  } catch (err) {
155
158
  return errorResult(err);
156
159
  }